Compare commits

..

No commits in common. "main" and "0.3.1-rc1" have entirely different histories.

177 changed files with 6053 additions and 13549 deletions

View File

@ -1,4 +0,0 @@
Dockerfile
.dockerignore
*.swp
*.swo

View File

@ -3,23 +3,45 @@ kind: pipeline
name: coopcloud.tech/abra name: coopcloud.tech/abra
steps: steps:
- name: make check - name: make check
image: golang:1.21 image: golang:1.17
commands: commands:
- make check - make check
- name: make static
image: golang:1.17
ignore: true # until we decide we all want this check
environment:
STATIC_CHECK_URL: honnef.co/go/tools/cmd/staticcheck
STATIC_CHECK_VERSION: v0.2.0
commands:
- go install $STATIC_CHECK_URL@$STATIC_CHECK_VERSION
- make static
- name: make build - name: make build
image: golang:1.21 image: golang:1.17
commands: commands:
- make build - make build
depends_on:
- make check
- name: make test - name: make test
image: golang:1.21 image: golang:1.17
commands: commands:
- make test - make test
- name: notify on failure
image: plugins/matrix
settings:
homeserver: https://matrix.autonomic.zone
roomid: "IFazIpLtxiScqbHqoa:autonomic.zone"
userid: "@autono-bot:autonomic.zone"
accesstoken:
from_secret: autono_bot_access_token
depends_on: depends_on:
- make check - make check
- make build
- make test
when:
status:
- failure
- name: fetch - name: fetch
image: docker:git image: docker:git
@ -33,7 +55,7 @@ steps:
event: tag event: tag
- name: release - name: release
image: goreleaser/goreleaser:v1.18.2 image: golang:1.17
environment: environment:
GITEA_TOKEN: GITEA_TOKEN:
from_secret: goreleaser_gitea_token from_secret: goreleaser_gitea_token
@ -41,29 +63,12 @@ steps:
- name: deps - name: deps
path: /go path: /go
commands: commands:
- goreleaser release - curl -sL https://git.io/goreleaser | bash
depends_on: depends_on:
- fetch - fetch
when: when:
event: tag event: tag
- name: publish image
image: plugins/docker
settings:
auto_tag: true
username: 3wordchant
password:
from_secret: git_coopcloud_tech_token_3wc
repo: git.coopcloud.tech/coop-cloud/abra
tags: dev
registry: git.coopcloud.tech
when:
event:
exclude:
- pull_request
depends_on:
- make check
volumes: volumes:
- name: deps - name: deps
temp: {} temp: {}

View File

@ -1,7 +1,6 @@
go env -w GOPRIVATE=coopcloud.tech go env -w GOPRIVATE=coopcloud.tech
# export PASSWORD_STORE_DIR=$(pwd)/../../autonomic/passwords/passwords/ # export PASSWORD_STORE_DIR=$(pwd)/../../autonomic/passwords/passwords/
# export HCLOUD_TOKEN=$(pass show logins/hetzner/cicd/api_key)
# export ABRA_DIR="$HOME/.abra_test" # export CAPSUL_TOKEN=...
# export ABRA_TEST_DOMAIN=test.example.com # export GITEA_TOKEN=...
# export ABRA_SKIP_TEARDOWN=1 # for faster feedback when developing tests

11
.gitignore vendored
View File

@ -1,9 +1,6 @@
*fmtcoverage.html
.e2e.env
.envrc
.vscode/
/kadabra
abra abra
dist/ .vscode/
tests/integration/.bats
vendor/ vendor/
.envrc
dist/
*fmtcoverage.html

View File

@ -1,76 +1,38 @@
--- ---
project_name: abra
gitea_urls: gitea_urls:
api: https://git.coopcloud.tech/api/v1 api: https://git.coopcloud.tech/api/v1
download: https://git.coopcloud.tech/ download: https://git.coopcloud.tech/
skip_tls_verify: false skip_tls_verify: false
before: before:
hooks: hooks:
- go mod tidy - go mod tidy
- go generate ./...
builds: builds:
- id: abra - env:
binary: abra - CGO_ENABLED=0
dir: cmd/abra dir: cmd/abra
env:
- CGO_ENABLED=0
goos: goos:
- linux - linux
- darwin - darwin
goarch:
- 386
- amd64
- arm
- arm64
goarm:
- 5
- 6
- 7
ldflags: ldflags:
- "-X 'main.Commit={{ .Commit }}'" - "-X 'main.Commit={{ .Commit }}'"
- "-X 'main.Version={{ .Version }}'" - "-X 'main.Version={{ .Version }}'"
- id: kadabra
binary: kadabra
dir: cmd/kadabra
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
goarch:
- 386
- amd64
- arm
- arm64
goarm:
- 5
- 6
- 7
ldflags:
- "-X 'main.Commit={{ .Commit }}'"
- "-X 'main.Version={{ .Version }}'"
archives: archives:
- replacements: - replacements:
386: i386 386: i386
amd64: x86_64 amd64: x86_64
format: binary format: binary
checksum: checksum:
name_template: "checksums.txt" name_template: "checksums.txt"
snapshot: snapshot:
name_template: "{{ incpatch .Version }}-next" name_template: "{{ incpatch .Version }}-next"
changelog: changelog:
sort: desc sort: desc
filters: filters:
exclude: exclude:
- "^Merge"
- "^Revert"
- "^WIP:" - "^WIP:"
- "^chore(deps):"
- "^style:" - "^style:"
- "^test:" - "^test:"
- "^tests:" - "^tests:"
- "^Revert"

View File

@ -1,17 +0,0 @@
# authors
> If you're looking at this and you hack on `abra` and you're not listed here,
> please do add yourself! This is a community project, let's show some 💞
- 3wordchant
- cassowary
- codegod100
- decentral1se
- frando
- kawaiipunk
- knoflook
- moritz
- rix
- roxxers
- vera
- yksflip

View File

@ -1,17 +0,0 @@
FROM golang:1.21-alpine AS build
ENV GOPRIVATE coopcloud.tech
RUN apk add --no-cache make git gcc musl-dev
COPY . /app
WORKDIR /app
RUN CGO_ENABLED=0 make build
FROM scratch
COPY --from=build /app/abra /abra
ENTRYPOINT ["/abra"]

15
LICENSE
View File

@ -1,15 +0,0 @@
Abra: The Co-op Cloud utility belt
Copyright (C) 2022 Co-op Cloud <helo@coopcloud.tech>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.

View File

@ -1,49 +1,45 @@
ABRA := ./cmd/abra ABRA := ./cmd/abra
KADABRA := ./cmd/kadabra
COMMIT := $(shell git rev-list -1 HEAD) COMMIT := $(shell git rev-list -1 HEAD)
GOPATH := $(shell go env GOPATH) GOPATH := $(shell go env GOPATH)
LDFLAGS := "-X 'main.Commit=$(COMMIT)'" LDFLAGS := "-X 'main.Commit=$(COMMIT)'"
DIST_LDFLAGS := $(LDFLAGS)" -s -w" DIST_LDFLAGS := $(LDFLAGS)" -s -w"
export GOPRIVATE=coopcloud.tech export GOPRIVATE=coopcloud.tech
all: format check build test all: run test install build clean format check static
run-abra: run:
@go run -ldflags=$(LDFLAGS) $(ABRA) @go run -ldflags=$(LDFLAGS) $(ABRA)
run-kadabra: install:
@go run -ldflags=$(LDFLAGS) $(KADABRA)
install-abra:
@go install -ldflags=$(LDFLAGS) $(ABRA) @go install -ldflags=$(LDFLAGS) $(ABRA)
install-kadaabra: build-dev:
@go install -ldflags=$(LDFLAGS) $(KADABRA) @go build -ldflags=$(LDFLAGS) $(ABRA)
build-abra:
@go build -v -ldflags=$(LDFLAGS) $(ABRA)
build-kadabra:
@go build -v -ldflags=$(LDFLAGS) $(KADABRA)
build: build:
@go build -v -ldflags=$(DIST_LDFLAGS) $(ABRA) @go build -ldflags=$(DIST_LDFLAGS) $(ABRA)
@go build -v -ldflags=$(DIST_LDFLAGS) $(KADABRA)
clean: clean:
@rm '$(GOPATH)/bin/abra' @rm '$(GOPATH)/bin/abra'
@rm '$(GOPATH)/bin/kadabra'
format: format:
@gofmt -s -w . @gofmt -s -w .
check: check:
@test -z $$(gofmt -l .) || \ @test -z $$(gofmt -l .) || (echo "gofmt: formatting issue - run 'make format' to resolve" && exit 1)
(echo "gofmt: formatting issue - run 'make format' to resolve" && exit 1)
static:
@staticcheck $(ABRA)
test: test:
@go test ./... -cover -v @go test ./... -cover -v
loc: loc:
@find . -name "*.go" | xargs wc -l @find . -name "*.go" | xargs wc -l
loc-author:
@git ls-files -z | \
xargs -0rn 1 -P "$$(nproc)" -I{} sh -c 'git blame -w -M -C -C --line-porcelain -- {} | grep -I --line-buffered "^author "' | \
sort -f | \
uniq -ic | \
sort -n

View File

@ -1,13 +1,61 @@
# `abra` # abra
> https://coopcloud.tech
[![Build Status](https://build.coopcloud.tech/api/badges/coop-cloud/abra/status.svg?ref=refs/heads/main)](https://build.coopcloud.tech/coop-cloud/abra) [![Build Status](https://build.coopcloud.tech/api/badges/coop-cloud/abra/status.svg?ref=refs/heads/main)](https://build.coopcloud.tech/coop-cloud/abra)
[![Go Report Card](https://goreportcard.com/badge/git.coopcloud.tech/coop-cloud/abra)](https://goreportcard.com/report/git.coopcloud.tech/coop-cloud/abra) [![Go Report Card](https://goreportcard.com/badge/git.coopcloud.tech/coop-cloud/abra)](https://goreportcard.com/report/git.coopcloud.tech/coop-cloud/abra)
[![Go Reference](https://pkg.go.dev/badge/coopcloud.tech/abra.svg)](https://pkg.go.dev/coopcloud.tech/abra)
The Co-op Cloud utility belt 🎩🐇 The Co-op Cloud utility belt 🎩🐇
<a href="https://github.com/egonelbre/gophers"><img align="right" width="150" src="https://github.com/egonelbre/gophers/raw/master/.thumb/sketch/adventure/poking-fire.png"/></a> `abra` is a command-line tool for managing your own [Co-op Cloud](https://coopcloud.tech). It can provision new servers, create apps, deploy them, run backup and restore operations and a whole lot of other things. Please see [docs.coopcloud.tech](https://docs.coopcloud.tech) for more extensive documentation.
`abra` is the flagship client & command-line tool for Co-op Cloud. It has been developed specifically for the purpose of making the day-to-day operations of [operators](https://docs.coopcloud.tech/operators/) and [maintainers](https://docs.coopcloud.tech/maintainers/) pleasant & convenient. It is libre software, written in [Go](https://go.dev) and maintained and extended by the community 💖 ## Hacking
Please see [docs.coopcloud.tech/abra](https://docs.coopcloud.tech/abra) for help on install, upgrade, hacking, troubleshooting & more! ### Getting started
Install [direnv](https://direnv.net), run `cp .envrc.sample .envrc`, then run `direnv allow` in this directory. This will set coopcloud repos as private due to [this bug.](https://git.coopcloud.tech/coop-cloud/coopcloud.tech/issues/20#issuecomment-8201). Or you can run `go env -w GOPRIVATE=coopcloud.tech` but I'm not sure how persistent this is.
Install [Go >= 1.16](https://golang.org/doc/install) and then:
- `make build` to build
- `./abra` to run commands
- `make test` will run tests
- `make install` will install it to `$GOPATH/bin`
- `go get <package>` and `go mod tidy` to add a new dependency
Our [Drone CI configuration](.drone.yml) runs a number of sanity on each pushed commit. See the [Makefile](./Makefile) for more handy targets.
Please use the [conventional commit format](https://www.conventionalcommits.org/en/v1.0.0/) for your commits so we can automate our change log.
### Versioning
We use [goreleaser](https://goreleaser.com) to help us automate releases. We use [semver](https://semver.org) for versioning all releases of the tool. While we are still in the public alpha release phase, we will maintain a `0.y.z-alpha` format. Change logs are generated from our commit logs. We are still working this out and aim to refine our release praxis as we go.
For developers, while using this `-alpha` format, the `y` part is the "major" version part. So, if you make breaking changes, you increment that and _not_ the `x` part. So, if you're on `0.1.0-alpha`, then you'd go to `0.1.1-alpha` for a backwards compatible change and `0.2.0-alpha` for a backwards incompatible change.
### Making a new release
- Change `ABRA_VERSION` to match the new tag in [`scripts`](./scripts/installer/installer) (use [semver](https://semver.org))
- Commit that change (e.g. `git commit -m 'chore: publish next tag x.y.z-alpha'`)
- Make a new tag (e.g. `git tag -a x.y.z-alpha`)
- Push the new tag (e.g. `git push && git push --tags`)
- Wait until the build finishes on [build.coopcloud.tech](https://build.coopcloud.tech/coop-cloud/abra)
- Deploy the new installer script (e.g. `cd ./scripts/installer && make`)
- Check the release worked, (e.g. `abra upgrade; abra -v`)
### Fork maintenance
#### `godotenv`
We maintain a fork of [godotenv](https://github.com/Autonomic-Cooperative/godotenv) for two features:
1. multi-line env var support
2. inline comment parsing
You can upgrade the version here by running `go get github.com/Autonomic-Cooperative/godotenv@<commit>` where `<commit>` is the
latest commit you want to pin to. We are aiming to migrate to YAML format for the environment configuration, so this should only
be a temporary thing.
#### `docker/client`
A number of modules in [pkg/upstream](./pkg/upstream) are copy/pasta'd from the upstream [docker/docker/client](https://pkg.go.dev/github.com/docker/docker/client). We had to do this because upstream are not exposing their API as public.

View File

@ -1,37 +1,38 @@
package app package app
import ( import (
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var AppCommand = cli.Command{ // AppCommand defines the `abra app` command and ets subcommands
var AppCommand = &cli.Command{
Name: "app", Name: "app",
Usage: "Manage deployed apps",
Aliases: []string{"a"}, Aliases: []string{"a"},
Usage: "Manage apps", ArgsUsage: "<app>",
ArgsUsage: "<domain>", Description: `
Description: "Functionality for managing the life cycle of your apps", This command provides all the functionality you need to manage the life cycle
Subcommands: []cli.Command{ of your apps. From initial deployment, day-2 operations (e.g. backup/restore)
appBackupCommand, to scaling apps up and spinning them down.
appCheckCommand, `,
appCmdCommand, Subcommands: []*cli.Command{
appConfigCommand,
appCpCommand,
appDeployCommand,
appErrorsCommand,
appListCommand,
appLogsCommand,
appNewCommand, appNewCommand,
appPsCommand, appConfigCommand,
appRemoveCommand, appDeployCommand,
appRestartCommand,
appRestoreCommand,
appRollbackCommand,
appRunCommand,
appSecretCommand,
appServicesCommand,
appUndeployCommand,
appUpgradeCommand, appUpgradeCommand,
appVersionCommand, appUndeployCommand,
appBackupCommand,
appRestoreCommand,
appRemoveCommand,
appCheckCommand,
appListCommand,
appPsCommand,
appLogsCommand,
appCpCommand,
appRunCommand,
appRollbackCommand,
appSecretCommand,
appVolumeCommand, appVolumeCommand,
appVersionCommand,
}, },
} }

View File

@ -1,414 +1,87 @@
package app package app
import ( import (
"archive/tar" "errors"
"context"
"fmt" "fmt"
"io" "io/ioutil"
"os" "os"
"path/filepath" "os/exec"
"path"
"strings" "strings"
"time"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
containerPkg "coopcloud.tech/abra/pkg/container"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/upstream/container"
"github.com/docker/cli/cli/command"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
dockerClient "github.com/docker/docker/client"
"github.com/docker/docker/pkg/archive"
"github.com/klauspost/pgzip"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
type backupConfig struct { var backupAllServices bool
preHookCmd string var backupAllServicesFlag = &cli.BoolFlag{
postHookCmd string Name: "all",
backupPaths []string Value: false,
Destination: &backupAllServices,
Aliases: []string{"a"},
Usage: "Backup all services",
} }
var appBackupCommand = cli.Command{ var appBackupCommand = &cli.Command{
Name: "backup", Name: "backup",
Aliases: []string{"bk"}, Usage: "Backup an app",
Usage: "Run app backup", Aliases: []string{"b"},
ArgsUsage: "<domain> [<service>]", Flags: []cli.Flag{backupAllServicesFlag},
Flags: []cli.Flag{ ArgsUsage: "<service>",
internal.DebugFlag,
internal.OfflineFlag,
internal.ChaosFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.AppNameComplete,
Description: `
Run an app backup.
A backup command and pre/post hook commands are defined in the recipe
configuration. Abra reads this configuration and run the comands in the context
of the deployed services. Pass <service> if you only want to back up a single
service. All backups are placed in the ~/.abra/backups directory.
A single backup file is produced for all backup paths specified for a service.
If we have the following backup configuration:
- "backupbot.backup.path=/var/lib/foo,/var/lib/bar"
And we run "abra app backup example.com app", Abra will produce a file that
looks like:
~/.abra/backups/example_com_app_609341138.tar.gz
This file is a compressed archive which contains all backup paths. To see paths, run:
tar -tf ~/.abra/backups/example_com_app_609341138.tar.gz
(Make sure to change the name of the backup file)
This single file can be used to restore your app. See "abra app restore" for more.
`,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
recipe, err := recipePkg.Get(app.Recipe, internal.Offline) if c.Args().Get(1) != "" && backupAllServices {
if err != nil { internal.ShowSubcommandHelpAndError(c, errors.New("cannot use '<service>' and '--all' together"))
}
abraSh := path.Join(config.ABRA_DIR, "apps", app.Type, "abra.sh")
if _, err := os.Stat(abraSh); err != nil {
if os.IsNotExist(err) {
logrus.Fatalf("'%s' does not exist?", abraSh)
}
logrus.Fatal(err) logrus.Fatal(err)
} }
if !internal.Chaos { sourceCmd := fmt.Sprintf("source %s", abraSh)
if err := recipePkg.EnsureIsClean(app.Recipe); err != nil {
logrus.Fatal(err)
}
if !internal.Offline {
if err := recipePkg.EnsureUpToDate(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
if err := recipePkg.EnsureLatest(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
backupConfigs := make(map[string]backupConfig)
for _, service := range recipe.Config.Services {
if backupsEnabled, ok := service.Deploy.Labels["backupbot.backup"]; ok {
if backupsEnabled == "true" {
fullServiceName := fmt.Sprintf("%s_%s", app.StackName(), service.Name)
bkConfig := backupConfig{}
logrus.Debugf("backup config detected for %s", fullServiceName)
if paths, ok := service.Deploy.Labels["backupbot.backup.path"]; ok {
logrus.Debugf("detected backup paths for %s: %s", fullServiceName, paths)
bkConfig.backupPaths = strings.Split(paths, ",")
}
if preHookCmd, ok := service.Deploy.Labels["backupbot.backup.pre-hook"]; ok {
logrus.Debugf("detected pre-hook command for %s: %s", fullServiceName, preHookCmd)
bkConfig.preHookCmd = preHookCmd
}
if postHookCmd, ok := service.Deploy.Labels["backupbot.backup.post-hook"]; ok {
logrus.Debugf("detected post-hook command for %s: %s", fullServiceName, postHookCmd)
bkConfig.postHookCmd = postHookCmd
}
backupConfigs[service.Name] = bkConfig
}
}
}
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
}
execCmd := "abra_backup"
if !backupAllServices {
serviceName := c.Args().Get(1) serviceName := c.Args().Get(1)
if serviceName != "" { if serviceName == "" {
backupConfig, ok := backupConfigs[serviceName] internal.ShowSubcommandHelpAndError(c, errors.New("no service(s) target provided"))
if !ok { }
logrus.Fatalf("no backup config for %s? does %s exist?", serviceName, serviceName) execCmd = fmt.Sprintf("abra_backup_%s", serviceName)
} }
logrus.Infof("running backup for the %s service", serviceName) bytes, err := ioutil.ReadFile(abraSh)
if err != nil {
if err := runBackup(cl, app, serviceName, backupConfig); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} else { if !strings.Contains(string(bytes), execCmd) {
if len(backupConfigs) == 0 { logrus.Fatalf("%s doesn't have a '%s' function", app.Type, execCmd)
logrus.Fatalf("no backup configs discovered for %s?", app.Name)
} }
for serviceName, backupConfig := range backupConfigs { sourceAndExec := fmt.Sprintf("%s; %s", sourceCmd, execCmd)
logrus.Infof("running backup for the %s service", serviceName) cmd := exec.Command("bash", "-c", sourceAndExec)
if err := internal.RunCmd(cmd); err != nil {
if err := runBackup(cl, app, serviceName, backupConfig); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
}
}
return nil return nil
}, },
} BashComplete: func(c *cli.Context) {
appNames, err := config.GetAppNames()
// TimeStamp generates a file name friendly timestamp.
func TimeStamp() string {
ts := time.Now().UTC().Format(time.RFC3339)
return strings.Replace(ts, ":", "-", -1)
}
// runBackup does the actual backup logic.
func runBackup(cl *dockerClient.Client, app config.App, serviceName string, bkConfig backupConfig) error {
if len(bkConfig.backupPaths) == 0 {
return fmt.Errorf("backup paths are empty for %s?", serviceName)
}
// FIXME: avoid instantiating a new CLI
dcli, err := command.NewDockerCli()
if err != nil { if err != nil {
return err logrus.Warn(err)
} }
if c.NArg() > 0 {
filters := filters.NewArgs()
filters.Add("name", fmt.Sprintf("^%s_%s", app.StackName(), serviceName))
targetContainer, err := containerPkg.GetContainer(context.Background(), cl, filters, true)
if err != nil {
return err
}
fullServiceName := fmt.Sprintf("%s_%s", app.StackName(), serviceName)
if bkConfig.preHookCmd != "" {
splitCmd := internal.SafeSplit(bkConfig.preHookCmd)
logrus.Debugf("split pre-hook command for %s into %s", fullServiceName, splitCmd)
preHookExecOpts := types.ExecConfig{
AttachStderr: true,
AttachStdin: true,
AttachStdout: true,
Cmd: splitCmd,
Detach: false,
Tty: true,
}
if err := container.RunExec(dcli, cl, targetContainer.ID, &preHookExecOpts); err != nil {
return fmt.Errorf("failed to run %s on %s: %s", bkConfig.preHookCmd, targetContainer.ID, err.Error())
}
logrus.Infof("succesfully ran %s pre-hook command: %s", fullServiceName, bkConfig.preHookCmd)
}
var tempBackupPaths []string
for _, remoteBackupPath := range bkConfig.backupPaths {
sanitisedPath := strings.ReplaceAll(remoteBackupPath, "/", "_")
localBackupPath := filepath.Join(config.BACKUP_DIR, fmt.Sprintf("%s%s_%s.tar.gz", fullServiceName, sanitisedPath, TimeStamp()))
logrus.Debugf("temporarily backing up %s:%s to %s", fullServiceName, remoteBackupPath, localBackupPath)
logrus.Infof("backing up %s:%s", fullServiceName, remoteBackupPath)
content, _, err := cl.CopyFromContainer(context.Background(), targetContainer.ID, remoteBackupPath)
if err != nil {
logrus.Debugf("failed to copy %s from container: %s", remoteBackupPath, err.Error())
if err := cleanupTempArchives(tempBackupPaths); err != nil {
return fmt.Errorf("failed to clean up temporary archives: %s", err.Error())
}
return fmt.Errorf("failed to copy %s from container: %s", remoteBackupPath, err.Error())
}
defer content.Close()
_, srcBase := archive.SplitPathDirEntry(remoteBackupPath)
preArchive := archive.RebaseArchiveEntries(content, srcBase, remoteBackupPath)
if err := copyToFile(localBackupPath, preArchive); err != nil {
logrus.Debugf("failed to create tar archive (%s): %s", localBackupPath, err.Error())
if err := cleanupTempArchives(tempBackupPaths); err != nil {
return fmt.Errorf("failed to clean up temporary archives: %s", err.Error())
}
return fmt.Errorf("failed to create tar archive (%s): %s", localBackupPath, err.Error())
}
tempBackupPaths = append(tempBackupPaths, localBackupPath)
}
logrus.Infof("compressing and merging archives...")
if err := mergeArchives(tempBackupPaths, fullServiceName); err != nil {
logrus.Debugf("failed to merge archive files: %s", err.Error())
if err := cleanupTempArchives(tempBackupPaths); err != nil {
return fmt.Errorf("failed to clean up temporary archives: %s", err.Error())
}
return fmt.Errorf("failed to merge archive files: %s", err.Error())
}
if err := cleanupTempArchives(tempBackupPaths); err != nil {
return fmt.Errorf("failed to clean up temporary archives: %s", err.Error())
}
if bkConfig.postHookCmd != "" {
splitCmd := internal.SafeSplit(bkConfig.postHookCmd)
logrus.Debugf("split post-hook command for %s into %s", fullServiceName, splitCmd)
postHookExecOpts := types.ExecConfig{
AttachStderr: true,
AttachStdin: true,
AttachStdout: true,
Cmd: splitCmd,
Detach: false,
Tty: true,
}
if err := container.RunExec(dcli, cl, targetContainer.ID, &postHookExecOpts); err != nil {
return err
}
logrus.Infof("succesfully ran %s post-hook command: %s", fullServiceName, bkConfig.postHookCmd)
}
return nil
}
func copyToFile(outfile string, r io.Reader) error {
tmpFile, err := os.CreateTemp(filepath.Dir(outfile), ".tar_temp")
if err != nil {
return err
}
tmpPath := tmpFile.Name()
_, err = io.Copy(tmpFile, r)
tmpFile.Close()
if err != nil {
os.Remove(tmpPath)
return err
}
if err = os.Rename(tmpPath, outfile); err != nil {
os.Remove(tmpPath)
return err
}
return nil
}
func cleanupTempArchives(tarPaths []string) error {
for _, tarPath := range tarPaths {
if err := os.RemoveAll(tarPath); err != nil {
return err
}
logrus.Debugf("remove temporary archive file %s", tarPath)
}
return nil
}
func mergeArchives(tarPaths []string, serviceName string) error {
var out io.Writer
var cout *pgzip.Writer
localBackupPath := filepath.Join(config.BACKUP_DIR, fmt.Sprintf("%s_%s.tar.gz", serviceName, TimeStamp()))
fout, err := os.Create(localBackupPath)
if err != nil {
return fmt.Errorf("Failed to open %s: %s", localBackupPath, err)
}
defer fout.Close()
out = fout
cout = pgzip.NewWriter(out)
out = cout
tw := tar.NewWriter(out)
for _, tarPath := range tarPaths {
if err := addTar(tw, tarPath); err != nil {
return fmt.Errorf("failed to merge %s: %v", tarPath, err)
}
}
if err := tw.Close(); err != nil {
return fmt.Errorf("failed to close tar writer %v", err)
}
if cout != nil {
if err := cout.Flush(); err != nil {
return fmt.Errorf("failed to flush: %s", err)
} else if err = cout.Close(); err != nil {
return fmt.Errorf("failed to close compressed writer: %s", err)
}
}
logrus.Infof("backed up %s to %s", serviceName, localBackupPath)
return nil
}
func addTar(tw *tar.Writer, pth string) (err error) {
var tr *tar.Reader
var rc io.ReadCloser
var hdr *tar.Header
if tr, rc, err = openTarFile(pth); err != nil {
return return
} }
for _, a := range appNames {
for { fmt.Println(a)
if hdr, err = tr.Next(); err != nil {
if err == io.EOF {
err = nil
} }
break },
}
if err = tw.WriteHeader(hdr); err != nil {
break
} else if _, err = io.Copy(tw, tr); err != nil {
break
}
}
if err == nil {
err = rc.Close()
} else {
rc.Close()
}
return
}
func openTarFile(pth string) (tr *tar.Reader, rc io.ReadCloser, err error) {
var fin *os.File
var n int
buff := make([]byte, 1024)
if fin, err = os.Open(pth); err != nil {
return
}
if n, err = fin.Read(buff); err != nil {
fin.Close()
return
} else if n == 0 {
fin.Close()
err = fmt.Errorf("%s is empty", pth)
return
}
if _, err = fin.Seek(0, 0); err != nil {
fin.Close()
return
}
rc = fin
tr = tar.NewReader(rc)
return tr, rc, nil
} }

View File

@ -1,58 +1,29 @@
package app package app
import ( import (
"fmt"
"os" "os"
"path" "path"
"strings" "strings"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/recipe"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var appCheckCommand = cli.Command{ var appCheckCommand = &cli.Command{
Name: "check", Name: "check",
Aliases: []string{"chk"}, Usage: "Check if app is configured correctly",
Usage: "Check if an app is configured correctly", Aliases: []string{"c"},
ArgsUsage: "<domain>", ArgsUsage: "<service>",
Flags: []cli.Flag{
internal.DebugFlag,
internal.ChaosFlag,
internal.OfflineFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
if err := recipe.EnsureExists(app.Recipe); err != nil { envSamplePath := path.Join(config.ABRA_DIR, "apps", app.Type, ".env.sample")
logrus.Fatal(err)
}
if !internal.Chaos {
if err := recipePkg.EnsureIsClean(app.Recipe); err != nil {
logrus.Fatal(err)
}
if !internal.Offline {
if err := recipePkg.EnsureUpToDate(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
if err := recipePkg.EnsureLatest(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
envSamplePath := path.Join(config.RECIPES_DIR, app.Recipe, ".env.sample")
if _, err := os.Stat(envSamplePath); err != nil { if _, err := os.Stat(envSamplePath); err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
logrus.Fatalf("%s does not exist?", envSamplePath) logrus.Fatalf("'%s' does not exist?", envSamplePath)
} }
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -74,8 +45,20 @@ var appCheckCommand = cli.Command{
logrus.Fatalf("%s is missing %s", app.Path, missingEnvVars) logrus.Fatalf("%s is missing %s", app.Path, missingEnvVars)
} }
logrus.Infof("all necessary environment variables defined for %s", app.Name) logrus.Infof("all necessary environment variables defined for '%s'", app.Name)
return nil return nil
}, },
BashComplete: func(c *cli.Context) {
appNames, err := config.GetAppNames()
if err != nil {
logrus.Warn(err)
}
if c.NArg() > 0 {
return
}
for _, a := range appNames {
fmt.Println(a)
}
},
} }

View File

@ -1,188 +0,0 @@
package app
import (
"errors"
"fmt"
"os"
"os/exec"
"path"
"strings"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/recipe"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
)
var appCmdCommand = cli.Command{
Name: "command",
Aliases: []string{"cmd"},
Usage: "Run app commands",
Description: `
Run an app specific command.
These commands are bash functions, defined in the abra.sh of the recipe itself.
They can be run within the context of a service (e.g. app) or locally on your
work station by passing "--local". Arguments can be passed into these functions
using the "-- <args>" syntax.
Example:
abra app cmd example.com app create_user -- me@example.com
`,
ArgsUsage: "<domain> [<service>] <command> [-- <args>]",
Flags: []cli.Flag{
internal.DebugFlag,
internal.LocalCmdFlag,
internal.RemoteUserFlag,
internal.TtyFlag,
internal.OfflineFlag,
internal.ChaosFlag,
},
BashComplete: autocomplete.AppNameComplete,
Before: internal.SubCommandBefore,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
if err := recipe.EnsureExists(app.Recipe); err != nil {
logrus.Fatal(err)
}
if !internal.Chaos {
if err := recipePkg.EnsureIsClean(app.Recipe); err != nil {
logrus.Fatal(err)
}
if !internal.Offline {
if err := recipePkg.EnsureUpToDate(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
if err := recipePkg.EnsureLatest(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
if internal.LocalCmd && internal.RemoteUser != "" {
internal.ShowSubcommandHelpAndError(c, errors.New("cannot use --local & --user together"))
}
hasCmdArgs, parsedCmdArgs := parseCmdArgs(c.Args(), internal.LocalCmd)
abraSh := path.Join(config.RECIPES_DIR, app.Recipe, "abra.sh")
if _, err := os.Stat(abraSh); err != nil {
if os.IsNotExist(err) {
logrus.Fatalf("%s does not exist for %s?", abraSh, app.Name)
}
logrus.Fatal(err)
}
if internal.LocalCmd {
if !(len(c.Args()) >= 2) {
internal.ShowSubcommandHelpAndError(c, errors.New("missing arguments"))
}
cmdName := c.Args().Get(1)
if err := internal.EnsureCommand(abraSh, app.Recipe, cmdName); err != nil {
logrus.Fatal(err)
}
logrus.Debugf("--local detected, running %s on local work station", cmdName)
var exportEnv string
for k, v := range app.Env {
exportEnv = exportEnv + fmt.Sprintf("%s='%s'; ", k, v)
}
var sourceAndExec string
if hasCmdArgs {
logrus.Debugf("parsed following command arguments: %s", parsedCmdArgs)
sourceAndExec = fmt.Sprintf("TARGET=local; APP_NAME=%s; STACK_NAME=%s; %s . %s; %s %s", app.Name, app.StackName(), exportEnv, abraSh, cmdName, parsedCmdArgs)
} else {
logrus.Debug("did not detect any command arguments")
sourceAndExec = fmt.Sprintf("TARGET=local; APP_NAME=%s; STACK_NAME=%s; %s . %s; %s", app.Name, app.StackName(), exportEnv, abraSh, cmdName)
}
shell := "/bin/bash"
if _, err := os.Stat(shell); errors.Is(err, os.ErrNotExist) {
logrus.Debugf("%s does not exist locally, use /bin/sh as fallback", shell)
shell = "/bin/sh"
}
cmd := exec.Command(shell, "-c", sourceAndExec)
if err := internal.RunCmd(cmd); err != nil {
logrus.Fatal(err)
}
} else {
if !(len(c.Args()) >= 3) {
internal.ShowSubcommandHelpAndError(c, errors.New("missing arguments"))
}
targetServiceName := c.Args().Get(1)
cmdName := c.Args().Get(2)
if err := internal.EnsureCommand(abraSh, app.Recipe, cmdName); err != nil {
logrus.Fatal(err)
}
serviceNames, err := config.GetAppServiceNames(app.Name)
if err != nil {
logrus.Fatal(err)
}
matchingServiceName := false
for _, serviceName := range serviceNames {
if serviceName == targetServiceName {
matchingServiceName = true
}
}
if !matchingServiceName {
logrus.Fatalf("no service %s for %s?", targetServiceName, app.Name)
}
logrus.Debugf("running command %s within the context of %s_%s", cmdName, app.StackName(), targetServiceName)
if hasCmdArgs {
logrus.Debugf("parsed following command arguments: %s", parsedCmdArgs)
} else {
logrus.Debug("did not detect any command arguments")
}
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
}
if err := internal.RunCmdRemote(cl, app, abraSh, targetServiceName, cmdName, parsedCmdArgs); err != nil {
logrus.Fatal(err)
}
}
return nil
},
}
func parseCmdArgs(args []string, isLocal bool) (bool, string) {
var (
parsedCmdArgs string
hasCmdArgs bool
)
if isLocal {
if len(args) > 2 {
return true, fmt.Sprintf("%s ", strings.Join(args[2:], " "))
}
} else {
if len(args) > 3 {
return true, fmt.Sprintf("%s ", strings.Join(args[3:], " "))
}
}
return hasCmdArgs, parsedCmdArgs
}

View File

@ -1,31 +0,0 @@
package app
import (
"strings"
"testing"
)
func TestParseCmdArgs(t *testing.T) {
tests := []struct {
input []string
shouldParse bool
expectedOutput string
}{
// `--` is not parsed when passed in from the command-line e.g. -- foo bar baz
// so we need to eumlate that as missing when testing if bash args are passed in
// see https://git.coopcloud.tech/coop-cloud/organising/issues/336 for more
{[]string{"foo.com", "app", "test"}, false, ""},
{[]string{"foo.com", "app", "test", "foo"}, true, "foo "},
{[]string{"foo.com", "app", "test", "foo", "bar", "baz"}, true, "foo bar baz "},
}
for _, test := range tests {
ok, parsed := parseCmdArgs(test.input, false)
if ok != test.shouldParse {
t.Fatalf("[%s] should not parse", strings.Join(test.input, " "))
}
if parsed != test.expectedOutput {
t.Fatalf("%s does not match %s", parsed, test.expectedOutput)
}
}
}

View File

@ -2,27 +2,21 @@ package app
import ( import (
"errors" "errors"
"fmt"
"os" "os"
"os/exec" "os/exec"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var appConfigCommand = cli.Command{ var appConfigCommand = &cli.Command{
Name: "config", Name: "config",
Aliases: []string{"cfg"}, Aliases: []string{"c"},
Usage: "Edit app config", Usage: "Edit app config",
ArgsUsage: "<domain>",
Flags: []cli.Flag{
internal.DebugFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
appName := c.Args().First() appName := c.Args().First()
@ -37,7 +31,7 @@ var appConfigCommand = cli.Command{
appFile, exists := files[appName] appFile, exists := files[appName]
if !exists { if !exists {
logrus.Fatalf("cannot find app with name %s", appName) logrus.Fatalf("cannot find app with name '%s'", appName)
} }
ed, ok := os.LookupEnv("EDITOR") ed, ok := os.LookupEnv("EDITOR")
@ -61,4 +55,16 @@ var appConfigCommand = cli.Command{
return nil return nil
}, },
BashComplete: func(c *cli.Context) {
appNames, err := config.GetAppNames()
if err != nil {
logrus.Warn(err)
}
if c.NArg() > 0 {
return
}
for _, a := range appNames {
fmt.Println(a)
}
},
} }

View File

@ -1,47 +1,26 @@
package app package app
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"strings" "strings"
"coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/container"
"coopcloud.tech/abra/pkg/formatter"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
dockerClient "github.com/docker/docker/client"
"github.com/docker/docker/pkg/archive" "github.com/docker/docker/pkg/archive"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var appCpCommand = cli.Command{ var appCpCommand = &cli.Command{
Name: "cp", Name: "cp",
Aliases: []string{"c"}, Aliases: []string{"c"},
ArgsUsage: "<domain> <src> <dst>", ArgsUsage: "<src> <dst>",
Flags: []cli.Flag{ Usage: "Copy files to/from a running app service",
internal.DebugFlag,
internal.NoInputFlag,
},
Before: internal.SubCommandBefore,
Usage: "Copy files to/from a deployed app service",
Description: `
Copy files to and from any app service file system.
If you want to copy a myfile.txt to the root of the app service:
abra app cp <domain> myfile.txt app:/
And if you want to copy that file back to your current working directory locally:
abra app cp <domain> app:/myfile.txt .
`,
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
@ -85,10 +64,14 @@ And if you want to copy that file back to your current working directory locally
logrus.Debugf("assuming transfer is going TO the container") logrus.Debugf("assuming transfer is going TO the container")
} }
if isToContainer { appFiles, err := config.LoadAppFiles("")
if _, err := os.Stat(srcPath); os.IsNotExist(err) { if err != nil {
logrus.Fatalf("%s does not exist locally?", srcPath) logrus.Fatal(err)
} }
appEnv, err := config.GetApp(appFiles, app.Name)
if err != nil {
logrus.Fatal(err)
} }
cl, err := client.New(app.Server) cl, err := client.New(app.Server)
@ -96,33 +79,25 @@ And if you want to copy that file back to your current working directory locally
logrus.Fatal(err) logrus.Fatal(err)
} }
if err := configureAndCp(c, cl, app, srcPath, dstPath, service, isToContainer); err != nil {
logrus.Fatal(err)
}
return nil
},
}
func configureAndCp(
c *cli.Context,
cl *dockerClient.Client,
app config.App,
srcPath string,
dstPath string,
service string,
isToContainer bool) error {
filters := filters.NewArgs() filters := filters.NewArgs()
filters.Add("name", fmt.Sprintf("^%s_%s", app.StackName(), service)) filters.Add("name", fmt.Sprintf("%s_%s", appEnv.StackName(), service))
containers, err := cl.ContainerList(c.Context, types.ContainerListOptions{Filters: filters})
container, err := container.GetContainer(context.Background(), cl, filters, internal.NoInput)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Debugf("retrieved %s as target container on %s", formatter.ShortenID(container.ID), app.Server) if len(containers) != 1 {
logrus.Fatalf("expected 1 container but got %v", len(containers))
}
container := containers[0]
logrus.Debugf("retrieved '%s' as target container on '%s'", formatter.ShortenID(container.ID), app.Server)
if isToContainer { if isToContainer {
if _, err := os.Stat(srcPath); err != nil {
logrus.Fatalf("'%s' does not exist?", srcPath)
}
toTarOpts := &archive.TarOptions{NoOverwriteDirNonDir: true, Compression: archive.Gzip} toTarOpts := &archive.TarOptions{NoOverwriteDirNonDir: true, Compression: archive.Gzip}
content, err := archive.TarWithOptions(srcPath, toTarOpts) content, err := archive.TarWithOptions(srcPath, toTarOpts)
if err != nil { if err != nil {
@ -130,11 +105,11 @@ func configureAndCp(
} }
copyOpts := types.CopyToContainerOptions{AllowOverwriteDirWithFile: false, CopyUIDGID: false} copyOpts := types.CopyToContainerOptions{AllowOverwriteDirWithFile: false, CopyUIDGID: false}
if err := cl.CopyToContainer(context.Background(), container.ID, dstPath, content, copyOpts); err != nil { if err := cl.CopyToContainer(c.Context, container.ID, dstPath, content, copyOpts); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} else { } else {
content, _, err := cl.CopyFromContainer(context.Background(), container.ID, srcPath) content, _, err := cl.CopyFromContainer(c.Context, container.ID, srcPath)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -144,6 +119,6 @@ func configureAndCp(
logrus.Fatal(err) logrus.Fatal(err)
} }
} }
return nil return nil
},
} }

View File

@ -1,227 +1,45 @@
package app package app
import ( import (
"context"
"fmt" "fmt"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/dns"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/lint"
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/upstream/stack"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var appDeployCommand = cli.Command{ var appDeployCommand = &cli.Command{
Name: "deploy", Name: "deploy",
Aliases: []string{"d"}, Aliases: []string{"d"},
Usage: "Deploy an app", Usage: "Deploy an app",
ArgsUsage: "<domain> [<version>]",
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.ForceFlag, internal.ForceFlag,
internal.ChaosFlag, internal.ChaosFlag,
internal.NoDomainChecksFlag,
internal.DontWaitConvergeFlag,
internal.OfflineFlag,
}, },
Before: internal.SubCommandBefore,
Description: ` Description: `
Deploy an app. It does not support incrementing the version of a deployed app, This command deploys a new instance of an app. It does not support changing the
for this you need to look at the "abra app upgrade <domain>" command. version of an existing deployed app, for this you need to look at the "abra app
upgrade <app>" command.
You may pass "--force" to re-deploy the same version again. This can be useful You may pass "--force" to re-deploy the same version again. This can be useful
if the container runtime has gotten into a weird state. if the container runtime has gotten into a weird state.
Chaos mode ("--chaos") will deploy your local checkout of a recipe as-is, Chas mode ("--chaos") will deploy your local checkout of a recipe as-is,
including unstaged changes and can be useful for live hacking and testing new including unstaged changes and can be useful for live hacking and testing new
recipes. recipes.
`, `,
BashComplete: autocomplete.AppNameComplete, Action: internal.DeployAction,
Action: func(c *cli.Context) error { BashComplete: func(c *cli.Context) {
app := internal.ValidateApp(c) appNames, err := config.GetAppNames()
stackName := app.StackName()
if err := recipe.EnsureExists(app.Recipe); err != nil {
logrus.Fatal(err)
}
if !internal.Chaos {
if err := recipe.EnsureIsClean(app.Recipe); err != nil {
logrus.Fatal(err)
}
if !internal.Offline {
if err := recipe.EnsureUpToDate(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
if err := recipe.EnsureLatest(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
r, err := recipe.Get(app.Recipe, internal.Offline)
if err != nil {
logrus.Fatal(err)
}
if err := lint.LintForErrors(r); err != nil {
logrus.Fatal(err)
}
logrus.Debugf("checking whether %s is already deployed", stackName)
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
}
isDeployed, deployedVersion, err := stack.IsDeployed(context.Background(), cl, stackName)
if err != nil {
logrus.Fatal(err)
}
if isDeployed {
if internal.Force || internal.Chaos {
logrus.Warnf("%s is already deployed but continuing (--force/--chaos)", app.Name)
} else {
logrus.Fatalf("%s is already deployed", app.Name)
}
}
version := deployedVersion
specificVersion := c.Args().Get(1)
if specificVersion != "" {
version = specificVersion
logrus.Debugf("choosing %s as version to deploy", version)
if err := recipe.EnsureVersion(app.Recipe, version); err != nil {
logrus.Fatal(err)
}
}
if !internal.Chaos && specificVersion == "" {
catl, err := recipe.ReadRecipeCatalogue(internal.Offline)
if err != nil {
logrus.Fatal(err)
}
versions, err := recipe.GetRecipeCatalogueVersions(app.Recipe, catl)
if err != nil {
logrus.Fatal(err)
}
if len(versions) == 0 && !internal.Chaos {
logrus.Warn("no published versions in catalogue, trying local recipe repository")
recipeVersions, err := recipe.GetRecipeVersions(app.Recipe, internal.Offline)
if err != nil { if err != nil {
logrus.Warn(err) logrus.Warn(err)
} }
for _, recipeVersion := range recipeVersions { if c.NArg() > 0 {
for version := range recipeVersion { return
versions = append(versions, version)
} }
for _, a := range appNames {
fmt.Println(a)
} }
}
if len(versions) > 0 && !internal.Chaos {
version = versions[len(versions)-1]
logrus.Debugf("choosing %s as version to deploy", version)
if err := recipe.EnsureVersion(app.Recipe, version); err != nil {
logrus.Fatal(err)
}
} else {
head, err := git.GetRecipeHead(app.Recipe)
if err != nil {
logrus.Fatal(err)
}
version = formatter.SmallSHA(head.String())
logrus.Warn("no versions detected, using latest commit")
}
}
if internal.Chaos {
logrus.Warnf("chaos mode engaged")
var err error
version, err = recipe.ChaosVersion(app.Recipe)
if err != nil {
logrus.Fatal(err)
}
}
abraShPath := fmt.Sprintf("%s/%s/%s", config.RECIPES_DIR, app.Recipe, "abra.sh")
abraShEnv, err := config.ReadAbraShEnvVars(abraShPath)
if err != nil {
logrus.Fatal(err)
}
for k, v := range abraShEnv {
app.Env[k] = v
}
composeFiles, err := config.GetAppComposeFiles(app.Recipe, app.Env)
if err != nil {
logrus.Fatal(err)
}
deployOpts := stack.Deploy{
Composefiles: composeFiles,
Namespace: stackName,
Prune: false,
ResolveImage: stack.ResolveImageAlways,
}
compose, err := config.GetAppComposeConfig(app.Name, deployOpts, app.Env)
if err != nil {
logrus.Fatal(err)
}
config.ExposeAllEnv(stackName, compose, app.Env)
config.SetRecipeLabel(compose, stackName, app.Recipe)
config.SetChaosLabel(compose, stackName, internal.Chaos)
config.SetChaosVersionLabel(compose, stackName, version)
config.SetUpdateLabel(compose, stackName, app.Env)
if err := internal.DeployOverview(app, version, "continue with deployment?"); err != nil {
logrus.Fatal(err)
}
if !internal.NoDomainChecks {
domainName, ok := app.Env["DOMAIN"]
if ok {
if _, err = dns.EnsureDomainsResolveSameIPv4(domainName, app.Server); err != nil {
logrus.Fatal(err)
}
} else {
logrus.Warn("skipping domain checks as no DOMAIN=... configured for app")
}
} else {
logrus.Warn("skipping domain checks as requested")
}
stack.WaitTimeout, err = config.GetTimeoutFromLabel(compose, stackName)
if err != nil {
logrus.Fatal(err)
}
logrus.Debugf("set waiting timeout to %d s", stack.WaitTimeout)
if err := stack.RunDeploy(cl, deployOpts, compose, app.Name, internal.DontWaitConverge); err != nil {
logrus.Fatal(err)
}
postDeployCmds, ok := app.Env["POST_DEPLOY_CMDS"]
if ok && !internal.DontWaitConverge {
logrus.Debugf("run the following post-deploy commands: %s", postDeployCmds)
if err := internal.PostCmds(cl, app, postDeployCmds); err != nil {
logrus.Fatalf("attempting to run post deploy commands, saw: %s", err)
}
}
return nil
}, },
} }

View File

@ -1,142 +0,0 @@
package app
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/recipe"
stack "coopcloud.tech/abra/pkg/upstream/stack"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
dockerClient "github.com/docker/docker/client"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
)
var appErrorsCommand = cli.Command{
Name: "errors",
Usage: "List errors for a deployed app",
ArgsUsage: "<domain>",
Description: `
List errors for a deployed app.
This is a best-effort implementation and an attempt to gather a number of tips
& tricks for finding errors together into one convenient command. When an app
is failing to deploy or having issues, it could be a lot of things.
This command currently takes into account:
Is the service deployed?
Is the service killed by an OOM error?
Is the service reporting an error (like in "ps --no-trunc" output)
Is the service healthcheck failing? what are the healthcheck logs?
Got any more ideas? Please let us know:
https://git.coopcloud.tech/coop-cloud/organising/issues/new/choose
This command is best accompanied by "abra app logs <domain>" which may reveal
further information which can help you debug the cause of an app failure via
the logs.
`,
Aliases: []string{"e"},
Flags: []cli.Flag{
internal.DebugFlag,
internal.WatchFlag,
internal.OfflineFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
}
isDeployed, _, err := stack.IsDeployed(context.Background(), cl, app.StackName())
if err != nil {
logrus.Fatal(err)
}
if !isDeployed {
logrus.Fatalf("%s is not deployed?", app.Name)
}
if !internal.Watch {
if err := checkErrors(c, cl, app); err != nil {
logrus.Fatal(err)
}
return nil
}
for {
if err := checkErrors(c, cl, app); err != nil {
logrus.Fatal(err)
}
time.Sleep(2 * time.Second)
}
},
}
func checkErrors(c *cli.Context, cl *dockerClient.Client, app config.App) error {
recipe, err := recipe.Get(app.Recipe, internal.Offline)
if err != nil {
return err
}
for _, service := range recipe.Config.Services {
filters := filters.NewArgs()
filters.Add("name", fmt.Sprintf("^%s_%s", app.StackName(), service.Name))
containers, err := cl.ContainerList(context.Background(), types.ContainerListOptions{Filters: filters})
if err != nil {
return err
}
if len(containers) == 0 {
logrus.Warnf("%s is not up, something seems wrong", service.Name)
continue
}
container := containers[0]
containerState, err := cl.ContainerInspect(context.Background(), container.ID)
if err != nil {
logrus.Fatal(err)
}
if containerState.State.OOMKilled {
logrus.Warnf("%s has been killed due to an out of memory error", service.Name)
}
if containerState.State.Error != "" {
logrus.Warnf("%s reports this error: %s", service.Name, containerState.State.Error)
}
if containerState.State.Health != nil {
if containerState.State.Health.Status != "healthy" {
logrus.Warnf("%s healthcheck status is %s", service.Name, containerState.State.Health.Status)
logrus.Warnf("%s healthcheck has failed %s times", service.Name, strconv.Itoa(containerState.State.Health.FailingStreak))
for _, log := range containerState.State.Health.Log {
logrus.Warnf("%s healthcheck logs: %s", service.Name, strings.TrimSpace(log.Output))
}
}
}
}
return nil
}
func getServiceName(names []string) string {
containerName := strings.Join(names, " ")
trimmed := strings.TrimPrefix(containerName, "/")
return strings.Split(trimmed, ".")[0]
}

View File

@ -1,183 +1,120 @@
package app package app
import ( import (
"encoding/json"
"fmt" "fmt"
"sort" "sort"
"strconv"
"strings" "strings"
"coopcloud.tech/abra/cli/internal" abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/tagcmp" "coopcloud.tech/tagcmp"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var status bool var status bool
var statusFlag = &cli.BoolFlag{ var statusFlag = &cli.BoolFlag{
Name: "status, S", Name: "status",
Aliases: []string{"S"},
Value: false,
Usage: "Show app deployment status", Usage: "Show app deployment status",
Destination: &status, Destination: &status,
} }
var recipeFilter string var appType string
var recipeFlag = &cli.StringFlag{ var typeFlag = &cli.StringFlag{
Name: "recipe, r", Name: "type",
Aliases: []string{"t"},
Value: "", Value: "",
Usage: "Show apps of a specific recipe", Usage: "Show apps of a specific type",
Destination: &recipeFilter, Destination: &appType,
} }
var listAppServer string var listAppServer string
var listAppServerFlag = &cli.StringFlag{ var listAppServerFlag = &cli.StringFlag{
Name: "server, s", Name: "server",
Aliases: []string{"s"},
Value: "", Value: "",
Usage: "Show apps of a specific server", Usage: "Show apps of a specific server",
Destination: &listAppServer, Destination: &listAppServer,
} }
type appStatus struct { var appListCommand = &cli.Command{
Server string `json:"server"`
Recipe string `json:"recipe"`
AppName string `json:"appName"`
Domain string `json:"domain"`
Status string `json:"status"`
Chaos string `json:"chaos"`
ChaosVersion string `json:"chaosVersion"`
AutoUpdate string `json:"autoUpdate"`
Version string `json:"version"`
Upgrade string `json:"upgrade"`
}
type serverStatus struct {
Apps []appStatus `json:"apps"`
AppCount int `json:"appCount"`
VersionCount int `json:"versionCount"`
UnversionedCount int `json:"unversionedCount"`
LatestCount int `json:"latestCount"`
UpgradeCount int `json:"upgradeCount"`
}
var appListCommand = cli.Command{
Name: "list", Name: "list",
Aliases: []string{"ls"},
Usage: "List all managed apps", Usage: "List all managed apps",
Description: ` Description: `
Read the local file system listing of apps and servers (e.g. ~/.abra/) to This command looks at your local file system listing of apps and servers (e.g.
generate a report of all your apps. in ~/.abra/) to generate a report of all your apps.
By passing the "--status/-S" flag, you can query all your servers for the By passing the "--status/-S" flag, you can query all your servers for the
actual live deployment status. Depending on how many servers you manage, this actual live deployment status. Depending on how many servers you manage, this
can take some time. can take some time.
`, `,
Aliases: []string{"ls"},
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.DebugFlag,
internal.MachineReadableFlag,
statusFlag, statusFlag,
listAppServerFlag, listAppServerFlag,
recipeFlag, typeFlag,
internal.OfflineFlag,
}, },
Before: internal.SubCommandBefore,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
appFiles, err := config.LoadAppFiles(listAppServer) appFiles, err := config.LoadAppFiles(listAppServer)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
apps, err := config.GetApps(appFiles, recipeFilter) apps, err := config.GetApps(appFiles)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
sort.Sort(config.ByServerAndType(apps))
sort.Sort(config.ByServerAndRecipe(apps))
statuses := make(map[string]map[string]string) statuses := make(map[string]map[string]string)
var catl recipe.RecipeCatalogue tableCol := []string{"Server", "Type", "Domain"}
if status { if status {
alreadySeen := make(map[string]bool) tableCol = append(tableCol, "Status", "Version", "Updates")
for _, app := range apps { statuses, err = config.GetAppStatuses(appFiles)
if _, ok := alreadySeen[app.Server]; !ok {
alreadySeen[app.Server] = true
}
}
statuses, err = config.GetAppStatuses(apps, internal.MachineReadable)
if err != nil {
logrus.Fatal(err)
}
catl, err = recipe.ReadRecipeCatalogue(internal.Offline)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} }
var totalServersCount int table := abraFormatter.CreateTable(tableCol)
var totalAppsCount int table.SetAutoMergeCellsByColumnIndex([]int{0})
allStats := make(map[string]serverStatus)
var (
versionedAppsCount int
unversionedAppsCount int
onLatestCount int
canUpgradeCount int
)
for _, app := range apps { for _, app := range apps {
var stats serverStatus var tableRow []string
var ok bool if app.Type == appType || appType == "" {
if stats, ok = allStats[app.Server]; !ok { // If type flag is set, check for it, if not, Type == ""
stats = serverStatus{} tableRow = []string{app.Server, app.Type, app.Domain}
if recipeFilter == "" {
// count server, no filtering
totalServersCount++
}
}
if app.Recipe == recipeFilter || recipeFilter == "" {
if recipeFilter != "" {
// only count server if matches filter
totalServersCount++
}
appStats := appStatus{}
stats.AppCount++
totalAppsCount++
if status { if status {
stackName := app.StackName()
status := "unknown" status := "unknown"
version := "unknown" version := "unknown"
chaos := "unknown" if statusMeta, ok := statuses[stackName]; ok {
chaosVersion := "unknown"
autoUpdate := "unknown"
if statusMeta, ok := statuses[app.StackName()]; ok {
if currentVersion, exists := statusMeta["version"]; exists { if currentVersion, exists := statusMeta["version"]; exists {
if currentVersion != "" {
version = currentVersion version = currentVersion
} }
}
if chaosDeploy, exists := statusMeta["chaos"]; exists {
chaos = chaosDeploy
}
if chaosDeployVersion, exists := statusMeta["chaosVersion"]; exists {
chaosVersion = chaosDeployVersion
}
if autoUpdateState, exists := statusMeta["autoUpdate"]; exists {
autoUpdate = autoUpdateState
}
if statusMeta["status"] != "" { if statusMeta["status"] != "" {
status = statusMeta["status"] status = statusMeta["status"]
} }
stats.VersionCount++ tableRow = append(tableRow, status, version)
versionedAppsCount++
} else { } else {
stats.UnversionedCount++ tableRow = append(tableRow, status, version)
unversionedAppsCount++
} }
appStats.Status = status
appStats.Chaos = chaos
appStats.ChaosVersion = chaosVersion
appStats.Version = version
appStats.AutoUpdate = autoUpdate
var newUpdates []string var newUpdates []string
if version != "unknown" { if version != "unknown" {
updates, err := recipe.GetRecipeCatalogueVersions(app.Recipe, catl) updates, err := catalogue.GetRecipeCatalogueVersions(app.Type)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -201,100 +138,36 @@ can take some time.
if len(newUpdates) == 0 { if len(newUpdates) == 0 {
if version == "unknown" { if version == "unknown" {
appStats.Upgrade = "unknown" tableRow = append(tableRow, "unknown")
} else { } else {
appStats.Upgrade = "latest" tableRow = append(tableRow, "on latest")
stats.LatestCount++ onLatestCount++
} }
} else { } else {
newUpdates = internal.ReverseStringList(newUpdates) // FIXME: jeezus golang why do you not have a list reverse function
appStats.Upgrade = strings.Join(newUpdates, "\n") for i, j := 0, len(newUpdates)-1; i < j; i, j = i+1, j-1 {
stats.UpgradeCount++ newUpdates[i], newUpdates[j] = newUpdates[j], newUpdates[i]
}
tableRow = append(tableRow, strings.Join(newUpdates, "\n"))
canUpgradeCount++
} }
} }
appStats.Server = app.Server
appStats.Recipe = app.Recipe
appStats.AppName = app.Name
appStats.Domain = app.Domain
stats.Apps = append(stats.Apps, appStats)
}
allStats[app.Server] = stats
}
if internal.MachineReadable {
jsonstring, err := json.Marshal(allStats)
if err != nil {
logrus.Fatal(err)
} else {
fmt.Println(string(jsonstring))
}
return nil
}
alreadySeen := make(map[string]bool)
for _, app := range apps {
if _, ok := alreadySeen[app.Server]; ok {
continue
}
serverStat := allStats[app.Server]
tableCol := []string{"recipe", "domain"}
if status {
tableCol = append(tableCol, []string{"status", "chaos", "version", "upgrade", "autoupdate"}...)
}
table := formatter.CreateTable(tableCol)
for _, appStat := range serverStat.Apps {
tableRow := []string{appStat.Recipe, appStat.Domain}
if status {
chaosStatus := appStat.Chaos
if chaosStatus != "unknown" {
chaosEnabled, err := strconv.ParseBool(chaosStatus)
if err != nil {
logrus.Fatal(err)
}
if chaosEnabled && appStat.ChaosVersion != "unknown" {
chaosStatus = appStat.ChaosVersion
}
}
tableRow = append(tableRow, []string{appStat.Status, chaosStatus, appStat.Version, appStat.Upgrade, appStat.AutoUpdate}...)
} }
table.Append(tableRow) table.Append(tableRow)
} }
if table.NumLines() > 0 { stats := fmt.Sprintf(
"Total apps: %v | Versioned: %v | Unversioned: %v | On latest: %v | Can upgrade: %v",
len(apps),
versionedAppsCount,
unversionedAppsCount,
onLatestCount,
canUpgradeCount,
)
table.SetCaption(true, stats)
table.Render() table.Render()
if status {
fmt.Println(fmt.Sprintf(
"server: %s | total apps: %v | versioned: %v | unversioned: %v | latest: %v | upgrade: %v",
app.Server,
serverStat.AppCount,
serverStat.VersionCount,
serverStat.UnversionedCount,
serverStat.LatestCount,
serverStat.UpgradeCount,
))
} else {
fmt.Println(fmt.Sprintf("server: %s | total apps: %v", app.Server, serverStat.AppCount))
}
}
if len(allStats) > 1 && table.NumLines() > 0 {
fmt.Println() // newline separator for multiple servers
}
alreadySeen[app.Server] = true
}
if len(allStats) > 1 {
fmt.Println(fmt.Sprintf("total servers: %v | total apps: %v ", totalServersCount, totalAppsCount))
}
return nil return nil
}, },
} }

View File

@ -1,45 +1,27 @@
package app package app
import ( import (
"context"
"fmt" "fmt"
"io" "io"
"os" "os"
"sync" "sync"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/service"
"coopcloud.tech/abra/pkg/upstream/stack"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
dockerClient "github.com/docker/docker/client" dockerClient "github.com/docker/docker/client"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var logOpts = types.ContainerLogsOptions{
ShowStderr: true,
ShowStdout: true,
Since: "",
Until: "",
Timestamps: true,
Follow: true,
Tail: "20",
Details: false,
}
// stackLogs lists logs for all stack services // stackLogs lists logs for all stack services
func stackLogs(c *cli.Context, app config.App, client *dockerClient.Client) { func stackLogs(c *cli.Context, stackName string, client *dockerClient.Client) {
filters, err := app.Filters(true, false) filters := filters.NewArgs()
if err != nil { filters.Add("name", stackName)
logrus.Fatal(err)
}
serviceOpts := types.ServiceListOptions{Filters: filters} serviceOpts := types.ServiceListOptions{Filters: filters}
services, err := client.ServiceList(context.Background(), serviceOpts) services, err := client.ServiceList(c.Context, serviceOpts)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -48,14 +30,19 @@ func stackLogs(c *cli.Context, app config.App, client *dockerClient.Client) {
for _, service := range services { for _, service := range services {
wg.Add(1) wg.Add(1)
go func(s string) { go func(s string) {
if internal.StdErrOnly { logOpts := types.ContainerLogsOptions{
logOpts.ShowStdout = false Details: true,
Follow: true,
ShowStderr: true,
ShowStdout: true,
Tail: "20",
Timestamps: true,
} }
logs, err := client.ServiceLogs(c.Context, s, logOpts)
logs, err := client.ServiceLogs(context.Background(), s, logOpts)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
// defer after err check as any err returns a nil io.ReadCloser
defer logs.Close() defer logs.Close()
_, err = io.Copy(os.Stdout, logs) _, err = io.Copy(os.Stdout, logs)
@ -64,76 +51,55 @@ func stackLogs(c *cli.Context, app config.App, client *dockerClient.Client) {
} }
}(service.ID) }(service.ID)
} }
wg.Wait() wg.Wait()
os.Exit(0) os.Exit(0)
} }
var appLogsCommand = cli.Command{ var appLogsCommand = &cli.Command{
Name: "logs", Name: "logs",
Aliases: []string{"l"}, Aliases: []string{"l"},
ArgsUsage: "<domain> [<service>]", ArgsUsage: "[<service>]",
Usage: "Tail app logs", Usage: "Tail app logs",
Flags: []cli.Flag{
internal.StdErrOnlyFlag,
internal.SinceLogsFlag,
internal.DebugFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
stackName := app.StackName()
cl, err := client.New(app.Server) cl, err := client.New(app.Server)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
isDeployed, _, err := stack.IsDeployed(context.Background(), cl, stackName)
if err != nil {
logrus.Fatal(err)
}
if !isDeployed {
logrus.Fatalf("%s is not deployed?", app.Name)
}
logOpts.Since = internal.SinceLogs
serviceName := c.Args().Get(1) serviceName := c.Args().Get(1)
if serviceName == "" { if serviceName == "" {
logrus.Debugf("tailing logs for all %s services", app.Recipe) logrus.Debug("tailing logs for all app services")
stackLogs(c, app, cl) stackLogs(c, app.StackName(), cl)
} else {
logrus.Debugf("tailing logs for %s", serviceName)
if err := tailServiceLogs(c, cl, app, serviceName); err != nil {
logrus.Fatal(err)
}
} }
logrus.Debugf("tailing logs for '%s'", serviceName)
return nil service := fmt.Sprintf("%s_%s", app.StackName(), serviceName)
},
}
func tailServiceLogs(c *cli.Context, cl *dockerClient.Client, app config.App, serviceName string) error {
filters := filters.NewArgs() filters := filters.NewArgs()
filters.Add("name", fmt.Sprintf("%s_%s", app.StackName(), serviceName)) filters.Add("name", service)
serviceOpts := types.ServiceListOptions{Filters: filters}
chosenService, err := service.GetService(context.Background(), cl, filters, internal.NoInput) services, err := cl.ServiceList(c.Context, serviceOpts)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if len(services) != 1 {
if internal.StdErrOnly { logrus.Fatalf("expected 1 service but got %v", len(services))
logOpts.ShowStdout = false
} }
logs, err := cl.ServiceLogs(context.Background(), chosenService.ID, logOpts) logOpts := types.ContainerLogsOptions{
Details: true,
Follow: true,
ShowStderr: true,
ShowStdout: true,
Tail: "20",
Timestamps: true,
}
logs, err := cl.ServiceLogs(c.Context, services[0].ID, logOpts)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
// defer after err check as any err returns a nil io.ReadCloser
defer logs.Close() defer logs.Close()
_, err = io.Copy(os.Stdout, logs) _, err = io.Copy(os.Stdout, logs)
@ -142,4 +108,17 @@ func tailServiceLogs(c *cli.Context, cl *dockerClient.Client, app config.App, se
} }
return nil return nil
},
BashComplete: func(c *cli.Context) {
appNames, err := config.GetAppNames()
if err != nil {
logrus.Warn(err)
}
if c.NArg() > 0 {
return
}
for _, a := range appNames {
fmt.Println(a)
}
},
} }

View File

@ -2,30 +2,19 @@ package app
import ( import (
"fmt" "fmt"
"path"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/app" "coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/jsontable"
"coopcloud.tech/abra/pkg/recipe"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/secret"
"github.com/AlecAivazis/survey/v2"
dockerClient "github.com/docker/docker/client"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var appNewDescription = ` var appNewDescription = `
Take a recipe and uses it to create a new app. This new app configuration is This command takes a recipe and uses it to create a new app. This new app
stored in your ~/.abra directory under the appropriate server. configuration is stored in your ~/.abra directory under the appropriate server.
This command does not deploy your app for you. You will need to run "abra app This command does not deploy your app for you. You will need to run "abra app
deploy <domain>" to do so. deploy <app>" to do so.
You can see what recipes are available (i.e. values for the <recipe> argument) You can see what recipes are available (i.e. values for the <recipe> argument)
by running "abra recipe ls". by running "abra recipe ls".
@ -40,216 +29,30 @@ pass store (see passwordstore.org for more). The pass command must be available
on your $PATH. on your $PATH.
` `
var appNewCommand = cli.Command{ var appNewCommand = &cli.Command{
Name: "new", Name: "new",
Aliases: []string{"n"},
Usage: "Create a new app", Usage: "Create a new app",
Aliases: []string{"n"},
Description: appNewDescription, Description: appNewDescription,
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.NewAppServerFlag, internal.NewAppServerFlag,
internal.DomainFlag, internal.DomainFlag,
internal.NewAppNameFlag,
internal.PassFlag, internal.PassFlag,
internal.SecretsFlag, internal.SecretsFlag,
internal.OfflineFlag,
}, },
Before: internal.SubCommandBefore, ArgsUsage: "<recipe>",
ArgsUsage: "[<recipe>]", Action: internal.NewAction,
BashComplete: autocomplete.RecipeNameComplete, BashComplete: func(c *cli.Context) {
Action: func(c *cli.Context) error { catl, err := catalogue.ReadRecipeCatalogue()
recipe := internal.ValidateRecipe(c)
if !internal.Offline {
if err := recipePkg.EnsureUpToDate(recipe.Name); err != nil {
logrus.Fatal(err)
}
}
if err := recipePkg.EnsureLatest(recipe.Name); err != nil {
logrus.Fatal(err)
}
if err := ensureServerFlag(); err != nil {
logrus.Fatal(err)
}
if err := ensureDomainFlag(recipe, internal.NewAppServer); err != nil {
logrus.Fatal(err)
}
sanitisedAppName := config.SanitiseAppName(internal.Domain)
logrus.Debugf("%s sanitised as %s for new app", internal.Domain, sanitisedAppName)
if err := config.TemplateAppEnvSample(
recipe.Name,
internal.Domain,
internal.NewAppServer,
internal.Domain,
); err != nil {
logrus.Fatal(err)
}
if err := promptForSecrets(internal.Domain); err != nil {
logrus.Fatal(err)
}
var secrets AppSecrets
var secretTable *jsontable.JSONTable
if internal.Secrets {
cl, err := client.New(internal.NewAppServer)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Warn(err)
} }
if c.NArg() > 0 {
secrets, err := createSecrets(cl, sanitisedAppName) return
if err != nil {
logrus.Fatal(err)
} }
for name := range catl {
secretCols := []string{"Name", "Value"} fmt.Println(name)
secretTable = formatter.CreateTable(secretCols)
for secret := range secrets {
secretTable.Append([]string{secret, secrets[secret]})
} }
}
if internal.NewAppServer == "default" {
internal.NewAppServer = "local"
}
tableCol := []string{"server", "recipe", "domain"}
table := formatter.CreateTable(tableCol)
table.Append([]string{internal.NewAppServer, recipe.Name, internal.Domain})
fmt.Println("")
fmt.Println(fmt.Sprintf("A new %s app has been created! Here is an overview:", recipe.Name))
fmt.Println("")
table.Render()
fmt.Println("")
fmt.Println("You can configure this app by running the following:")
fmt.Println(fmt.Sprintf("\n abra app config %s", internal.Domain))
fmt.Println("")
fmt.Println("You can deploy this app by running the following:")
fmt.Println(fmt.Sprintf("\n abra app deploy %s", internal.Domain))
fmt.Println("")
if len(secrets) > 0 {
fmt.Println("Here are your generated secrets:")
fmt.Println("")
secretTable.Render()
fmt.Println("")
logrus.Warn("generated secrets are not shown again, please take note of them *now*")
}
return nil
}, },
} }
// AppSecrets represents all app secrest
type AppSecrets map[string]string
// createSecrets creates all secrets for a new app.
func createSecrets(cl *dockerClient.Client, sanitisedAppName string) (AppSecrets, error) {
appEnvPath := path.Join(
config.ABRA_DIR,
"servers",
internal.NewAppServer,
fmt.Sprintf("%s.env", internal.Domain),
)
appEnv, err := config.ReadEnv(appEnvPath)
if err != nil {
return nil, err
}
secretEnvVars := secret.ReadSecretEnvVars(appEnv)
secrets, err := secret.GenerateSecrets(cl, secretEnvVars, sanitisedAppName, internal.NewAppServer)
if err != nil {
return nil, err
}
if internal.Pass {
for secretName := range secrets {
secretValue := secrets[secretName]
if err := secret.PassInsertSecret(
secretValue,
secretName,
internal.Domain,
internal.NewAppServer,
); err != nil {
return nil, err
}
}
}
return secrets, nil
}
// ensureDomainFlag checks if the domain flag was used. if not, asks the user for it/
func ensureDomainFlag(recipe recipe.Recipe, server string) error {
if internal.Domain == "" && !internal.NoInput {
prompt := &survey.Input{
Message: "Specify app domain",
Default: fmt.Sprintf("%s.%s", recipe.Name, server),
}
if err := survey.AskOne(prompt, &internal.Domain); err != nil {
return err
}
}
if internal.Domain == "" {
return fmt.Errorf("no domain provided")
}
return nil
}
// promptForSecrets asks if we should generate secrets for a new app.
func promptForSecrets(appName string) error {
app, err := app.Get(appName)
if err != nil {
return err
}
secretEnvVars := secret.ReadSecretEnvVars(app.Env)
if len(secretEnvVars) == 0 {
logrus.Debugf("%s has no secrets to generate, skipping...", app.Recipe)
return nil
}
if !internal.Secrets && !internal.NoInput {
prompt := &survey.Confirm{
Message: "Generate app secrets?",
}
if err := survey.AskOne(prompt, &internal.Secrets); err != nil {
return err
}
}
return nil
}
// ensureServerFlag checks if the server flag was used. if not, asks the user for it.
func ensureServerFlag() error {
servers, err := config.GetServers()
if err != nil {
return err
}
if internal.NewAppServer == "" && !internal.NoInput {
prompt := &survey.Select{
Message: "Select app server:",
Options: servers,
}
if err := survey.AskOne(prompt, &internal.NewAppServer); err != nil {
return err
}
}
if internal.NewAppServer == "" {
return fmt.Errorf("no server provided")
}
return nil
}

View File

@ -1,38 +1,65 @@
package app package app
import ( import (
"context" "fmt"
"strings" "strings"
"time" "time"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter" "github.com/docker/cli/cli/command/formatter"
"coopcloud.tech/abra/pkg/service"
stack "coopcloud.tech/abra/pkg/upstream/stack"
"github.com/buger/goterm"
dockerFormatter "github.com/docker/cli/cli/command/formatter"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
dockerClient "github.com/docker/docker/client" "github.com/docker/docker/api/types/filters"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var appPsCommand = cli.Command{ var watch bool
var watchFlag = &cli.BoolFlag{
Name: "watch",
Aliases: []string{"w"},
Value: false,
Usage: "Watch status by polling repeatedly",
Destination: &watch,
}
var appPsCommand = &cli.Command{
Name: "ps", Name: "ps",
Aliases: []string{"p"},
Usage: "Check app status", Usage: "Check app status",
ArgsUsage: "<domain>", Aliases: []string{"p"},
Description: "Show a more detailed status output of a specific deployed app",
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.WatchFlag, watchFlag,
internal.DebugFlag,
}, },
Before: internal.SubCommandBefore,
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
if !watch {
showPSOutput(c)
return nil
}
// TODO: how do we make this update in-place in an x-platform way?
for {
showPSOutput(c)
time.Sleep(2 * time.Second)
}
},
BashComplete: func(c *cli.Context) {
appNames, err := config.GetAppNames()
if err != nil {
logrus.Warn(err)
}
if c.NArg() > 0 {
return
}
for _, a := range appNames {
fmt.Println(a)
}
},
}
// showPSOutput renders ps output.
func showPSOutput(c *cli.Context) {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
cl, err := client.New(app.Server) cl, err := client.New(app.Server)
@ -40,44 +67,16 @@ var appPsCommand = cli.Command{
logrus.Fatal(err) logrus.Fatal(err)
} }
isDeployed, _, err := stack.IsDeployed(context.Background(), cl, app.StackName()) filters := filters.NewArgs()
filters.Add("name", app.StackName())
containers, err := cl.ContainerList(c.Context, types.ContainerListOptions{Filters: filters})
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if !isDeployed { tableCol := []string{"image", "created", "status", "ports", "names"}
logrus.Fatalf("%s is not deployed?", app.Name) table := abraFormatter.CreateTable(tableCol)
}
if !internal.Watch {
showPSOutput(c, app, cl)
return nil
}
goterm.Clear()
for {
goterm.MoveCursor(1, 1)
showPSOutput(c, app, cl)
goterm.Flush()
time.Sleep(2 * time.Second)
}
},
}
// showPSOutput renders ps output.
func showPSOutput(c *cli.Context, app config.App, cl *dockerClient.Client) {
filters, err := app.Filters(true, true)
if err != nil {
logrus.Fatal(err)
}
containers, err := cl.ContainerList(context.Background(), types.ContainerListOptions{Filters: filters})
if err != nil {
logrus.Fatal(err)
}
tableCol := []string{"service name", "image", "created", "status", "state", "ports"}
table := formatter.CreateTable(tableCol)
for _, container := range containers { for _, container := range containers {
var containerNames []string var containerNames []string
@ -87,12 +86,11 @@ func showPSOutput(c *cli.Context, app config.App, cl *dockerClient.Client) {
} }
tableRow := []string{ tableRow := []string{
service.ContainerToServiceName(container.Names, app.StackName()), abraFormatter.RemoveSha(container.Image),
formatter.RemoveSha(container.Image), abraFormatter.HumanDuration(container.Created),
formatter.HumanDuration(container.Created),
container.Status, container.Status,
container.State, formatter.DisplayablePorts(container.Ports),
dockerFormatter.DisplayablePorts(container.Ports), strings.Join(containerNames, "\n"),
} }
table.Append(tableRow) table.Append(tableRow)
} }

View File

@ -1,86 +1,76 @@
package app package app
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
stack "coopcloud.tech/abra/pkg/upstream/stack" "coopcloud.tech/abra/pkg/config"
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/volume" "github.com/docker/docker/api/types/filters"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var appRemoveCommand = cli.Command{ // Volumes stores the variable from VolumesFlag
var Volumes bool
// VolumesFlag is used to specify if volumes should be deleted when deleting an app
var VolumesFlag = &cli.BoolFlag{
Name: "volumes",
Value: false,
Destination: &Volumes,
}
var appRemoveCommand = &cli.Command{
Name: "remove", Name: "remove",
Usage: "Remove an already undeployed app",
Aliases: []string{"rm"}, Aliases: []string{"rm"},
ArgsUsage: "<domain>",
Usage: "Remove all app data, locally and remotely",
Description: `
This command removes everything related to an app which is already undeployed.
By default, it will prompt for confirmation before proceeding. All secrets,
volumes and the local app env file will be deleted.
Only run this command when you are sure you want to completely remove the app
and all associated app data. This is a destructive action, Be Careful!
If you would like to delete specific volumes or secrets, please use removal
sub-commands under "app volume" and "app secret" instead.
Please note, if you delete the local app env file without removing volumes and
secrets first, Abra will *not* be able to help you remove them afterwards.
To delete everything without prompt, use the "--force/-f" or the "--no-input/n"
flag.
`,
Flags: []cli.Flag{ Flags: []cli.Flag{
VolumesFlag,
internal.ForceFlag, internal.ForceFlag,
internal.DebugFlag,
internal.NoInputFlag,
internal.OfflineFlag,
}, },
BashComplete: autocomplete.AppNameComplete,
Before: internal.SubCommandBefore,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
if !internal.Force && !internal.NoInput { if !internal.Force {
response := false response := false
msg := "ALERTA ALERTA: this will completely remove %s data and configurations locally and remotely, are you sure?" prompt := &survey.Confirm{
prompt := &survey.Confirm{Message: fmt.Sprintf(msg, app.Name)} Message: fmt.Sprintf("about to delete %s, are you sure?", app.Name),
}
if err := survey.AskOne(prompt, &response); err != nil { if err := survey.AskOne(prompt, &response); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if !response { if !response {
logrus.Fatal("aborting as requested") logrus.Fatal("user aborted app removal")
} }
} }
appFiles, err := config.LoadAppFiles("")
if err != nil {
logrus.Fatal(err)
}
cl, err := client.New(app.Server) cl, err := client.New(app.Server)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if !internal.Force {
isDeployed, _, err := stack.IsDeployed(context.Background(), cl, app.StackName()) // FIXME: only query for app we are interested in, not all of them!
statuses, err := config.GetAppStatuses(appFiles)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if isDeployed { if statuses[app.Name]["status"] == "deployed" {
logrus.Fatalf("%s is still deployed. Run \"abra app undeploy %s\"", app.Name, app.Name) logrus.Fatalf("'%s' is still deployed. Run \"abra app %s undeploy\" or pass --force", app.Name, app.Name)
}
} }
fs, err := app.Filters(false, false) fs := filters.NewArgs()
if err != nil { fs.Add("name", app.Name)
logrus.Fatal(err) secretList, err := cl.SecretList(c.Context, types.SecretListOptions{Filters: fs})
}
secretList, err := cl.SecretList(context.Background(), types.SecretListOptions{Filters: fs})
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -94,8 +84,20 @@ flag.
} }
if len(secrets) > 0 { if len(secrets) > 0 {
for _, name := range secretNames { var secretNamesToRemove []string
err := cl.SecretRemove(context.Background(), secrets[name]) if !internal.Force {
secretsPrompt := &survey.MultiSelect{
Message: "which secrets do you want to remove?",
Options: secretNames,
Default: secretNames,
}
if err := survey.AskOne(secretsPrompt, &secretNamesToRemove); err != nil {
logrus.Fatal(err)
}
}
for _, name := range secretNamesToRemove {
err := cl.SecretRemove(c.Context, secrets[name])
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -105,13 +107,7 @@ flag.
logrus.Info("no secrets to remove") logrus.Info("no secrets to remove")
} }
fs, err = app.Filters(false, true) volumeListOKBody, err := cl.VolumeList(c.Context, fs)
if err != nil {
logrus.Fatal(err)
}
volumeListOptions := volume.ListOptions{fs}
volumeListOKBody, err := cl.VolumeList(context.Background(), volumeListOptions)
volumeList := volumeListOKBody.Volumes volumeList := volumeListOKBody.Volumes
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
@ -123,23 +119,50 @@ flag.
} }
if len(vols) > 0 { if len(vols) > 0 {
for _, vol := range vols { if Volumes {
err := cl.VolumeRemove(context.Background(), vol, internal.Force) // last argument is for force removing var removeVols []string
if !internal.Force {
volumesPrompt := &survey.MultiSelect{
Message: "which volumes do you want to remove?",
Options: vols,
Default: vols,
}
if err := survey.AskOne(volumesPrompt, &removeVols); err != nil {
logrus.Fatal(err)
}
}
for _, vol := range removeVols {
err := cl.VolumeRemove(c.Context, vol, internal.Force) // last argument is for force removing
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Info(fmt.Sprintf("volume %s removed", vol)) logrus.Info(fmt.Sprintf("volume %s removed", vol))
} }
} else {
logrus.Info("no volumes were removed")
}
} else { } else {
logrus.Info("no volumes to remove") logrus.Info("no volumes to remove")
} }
if err = os.Remove(app.Path); err != nil { err = os.Remove(app.Path)
if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Info(fmt.Sprintf("file: %s removed", app.Path)) logrus.Info(fmt.Sprintf("file: %s removed", app.Path))
return nil return nil
}, },
BashComplete: func(c *cli.Context) {
appNames, err := config.GetAppNames()
if err != nil {
logrus.Warn(err)
}
if c.NArg() > 0 {
return
}
for _, a := range appNames {
fmt.Println(a)
}
},
} }

View File

@ -1,80 +0,0 @@
package app
import (
"context"
"errors"
"fmt"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
upstream "coopcloud.tech/abra/pkg/upstream/service"
stack "coopcloud.tech/abra/pkg/upstream/stack"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
)
var appRestartCommand = cli.Command{
Name: "restart",
Aliases: []string{"re"},
Usage: "Restart an app",
ArgsUsage: "<domain>",
Flags: []cli.Flag{
internal.DebugFlag,
internal.OfflineFlag,
},
Before: internal.SubCommandBefore,
Description: `This command restarts a service within a deployed app.`,
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
serviceNameShort := c.Args().Get(1)
if serviceNameShort == "" {
err := errors.New("missing service?")
internal.ShowSubcommandHelpAndError(c, err)
}
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
}
isDeployed, _, err := stack.IsDeployed(context.Background(), cl, app.StackName())
if err != nil {
logrus.Fatal(err)
}
if !isDeployed {
logrus.Fatalf("%s is not deployed?", app.Name)
}
serviceName := fmt.Sprintf("%s_%s", app.StackName(), serviceNameShort)
logrus.Debugf("attempting to scale %s to 0 (restart logic)", serviceName)
if err := upstream.RunServiceScale(context.Background(), cl, serviceName, 0); err != nil {
logrus.Fatal(err)
}
if err := stack.WaitOnService(context.Background(), cl, serviceName, app.Name); err != nil {
logrus.Fatal(err)
}
logrus.Debugf("%s has been scaled to 0 (restart logic)", serviceName)
logrus.Debugf("attempting to scale %s to 1 (restart logic)", serviceName)
if err := upstream.RunServiceScale(context.Background(), cl, serviceName, 1); err != nil {
logrus.Fatal(err)
}
if err := stack.WaitOnService(context.Background(), cl, serviceName, app.Name); err != nil {
logrus.Fatal(err)
}
logrus.Debugf("%s has been scaled to 1 (restart logic)", serviceName)
logrus.Infof("%s service successfully restarted", serviceNameShort)
return nil
},
}

View File

@ -1,223 +1,79 @@
package app package app
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"io/ioutil"
"os" "os"
"os/exec"
"path"
"strings"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
containerPkg "coopcloud.tech/abra/pkg/container"
"coopcloud.tech/abra/pkg/recipe"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/upstream/container"
"github.com/docker/cli/cli/command"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
dockerClient "github.com/docker/docker/client"
"github.com/docker/docker/pkg/archive"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
type restoreConfig struct { var restoreAllServices bool
preHookCmd string var restoreAllServicesFlag = &cli.BoolFlag{
postHookCmd string Name: "all",
Value: false,
Destination: &restoreAllServices,
Aliases: []string{"a"},
Usage: "Restore all services",
} }
var appRestoreCommand = cli.Command{ var appRestoreCommand = &cli.Command{
Name: "restore", Name: "restore",
Aliases: []string{"rs"}, Usage: "Restore an app from a backup",
Usage: "Run app restore", Aliases: []string{"r"},
ArgsUsage: "<domain> <service> <file>", Flags: []cli.Flag{restoreAllServicesFlag},
Flags: []cli.Flag{ ArgsUsage: "<service> [<backup file>]",
internal.DebugFlag,
internal.OfflineFlag,
internal.ChaosFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.AppNameComplete,
Description: `
Run an app restore.
Pre/post hook commands are defined in the recipe configuration. Abra reads this
configuration and run the comands in the context of the service before
restoring the backup.
Unlike "abra app backup", restore must be run on a per-service basis. You can
not restore all services in one go. Backup files produced by Abra are
compressed archives which use absolute paths. This allows Abra to restore
according to standard tar command logic, i.e. the backup will be restored to
the path it was originally backed up from.
Example:
abra app restore example.com app ~/.abra/backups/example_com_app_609341138.tar.gz
`,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
recipe, err := recipe.Get(app.Recipe, internal.Offline) if c.Args().Len() > 1 && restoreAllServices {
if err != nil { internal.ShowSubcommandHelpAndError(c, errors.New("cannot use <service>/<backup file> and '--all' together"))
}
abraSh := path.Join(config.ABRA_DIR, "apps", app.Type, "abra.sh")
if _, err := os.Stat(abraSh); err != nil {
if os.IsNotExist(err) {
logrus.Fatalf("'%s' does not exist?", abraSh)
}
logrus.Fatal(err) logrus.Fatal(err)
} }
if !internal.Chaos { sourceCmd := fmt.Sprintf("source %s", abraSh)
if err := recipePkg.EnsureIsClean(app.Recipe); err != nil { execCmd := "abra_restore"
logrus.Fatal(err) if !restoreAllServices {
}
if !internal.Offline {
if err := recipePkg.EnsureUpToDate(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
if err := recipePkg.EnsureLatest(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
serviceName := c.Args().Get(1) serviceName := c.Args().Get(1)
if serviceName == "" { if serviceName == "" {
internal.ShowSubcommandHelpAndError(c, errors.New("missing <service>?")) internal.ShowSubcommandHelpAndError(c, errors.New("no service(s) target provided"))
}
execCmd = fmt.Sprintf("abra_restore_%s", serviceName)
} }
backupPath := c.Args().Get(2) bytes, err := ioutil.ReadFile(abraSh)
if backupPath == "" {
internal.ShowSubcommandHelpAndError(c, errors.New("missing <file>?"))
}
if _, err := os.Stat(backupPath); err != nil {
if os.IsNotExist(err) {
logrus.Fatalf("%s doesn't exist?", backupPath)
}
}
restoreConfigs := make(map[string]restoreConfig)
for _, service := range recipe.Config.Services {
if restoreEnabled, ok := service.Deploy.Labels["backupbot.restore"]; ok {
if restoreEnabled == "true" {
fullServiceName := fmt.Sprintf("%s_%s", app.StackName(), service.Name)
rsConfig := restoreConfig{}
logrus.Debugf("restore config detected for %s", fullServiceName)
if preHookCmd, ok := service.Deploy.Labels["backupbot.restore.pre-hook"]; ok {
logrus.Debugf("detected pre-hook command for %s: %s", fullServiceName, preHookCmd)
rsConfig.preHookCmd = preHookCmd
}
if postHookCmd, ok := service.Deploy.Labels["backupbot.restore.post-hook"]; ok {
logrus.Debugf("detected post-hook command for %s: %s", fullServiceName, postHookCmd)
rsConfig.postHookCmd = postHookCmd
}
restoreConfigs[service.Name] = rsConfig
}
}
}
rsConfig, ok := restoreConfigs[serviceName]
if !ok {
rsConfig = restoreConfig{}
}
cl, err := client.New(app.Server)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if !strings.Contains(string(bytes), execCmd) {
logrus.Fatalf("%s doesn't have a '%s' function", app.Type, execCmd)
}
if err := runRestore(cl, app, backupPath, serviceName, rsConfig); err != nil { backupFile := c.Args().Get(2)
if backupFile != "" {
execCmd = fmt.Sprintf("%s %s", execCmd, backupFile)
}
sourceAndExec := fmt.Sprintf("%s; %s", sourceCmd, execCmd)
cmd := exec.Command("bash", "-c", sourceAndExec)
if err := internal.RunCmd(cmd); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
return nil return nil
}, },
} }
// runRestore does the actual restore logic.
func runRestore(cl *dockerClient.Client, app config.App, backupPath, serviceName string, rsConfig restoreConfig) error {
// FIXME: avoid instantiating a new CLI
dcli, err := command.NewDockerCli()
if err != nil {
return err
}
filters := filters.NewArgs()
filters.Add("name", fmt.Sprintf("^%s_%s", app.StackName(), serviceName))
targetContainer, err := containerPkg.GetContainer(context.Background(), cl, filters, true)
if err != nil {
return err
}
fullServiceName := fmt.Sprintf("%s_%s", app.StackName(), serviceName)
if rsConfig.preHookCmd != "" {
splitCmd := internal.SafeSplit(rsConfig.preHookCmd)
logrus.Debugf("split pre-hook command for %s into %s", fullServiceName, splitCmd)
preHookExecOpts := types.ExecConfig{
AttachStderr: true,
AttachStdin: true,
AttachStdout: true,
Cmd: splitCmd,
Detach: false,
Tty: true,
}
if err := container.RunExec(dcli, cl, targetContainer.ID, &preHookExecOpts); err != nil {
return err
}
logrus.Infof("succesfully ran %s pre-hook command: %s", fullServiceName, rsConfig.preHookCmd)
}
backupReader, err := os.Open(backupPath)
if err != nil {
return err
}
content, err := archive.DecompressStream(backupReader)
if err != nil {
return err
}
// NOTE(d1): we use absolute paths so tar knows what to do. it will restore
// files according to the paths set in the compressed archive
restorePath := "/"
copyOpts := types.CopyToContainerOptions{AllowOverwriteDirWithFile: false, CopyUIDGID: false}
if err := cl.CopyToContainer(context.Background(), targetContainer.ID, restorePath, content, copyOpts); err != nil {
return err
}
logrus.Infof("restored %s to %s", backupPath, fullServiceName)
if rsConfig.postHookCmd != "" {
splitCmd := internal.SafeSplit(rsConfig.postHookCmd)
logrus.Debugf("split post-hook command for %s into %s", fullServiceName, splitCmd)
postHookExecOpts := types.ExecConfig{
AttachStderr: true,
AttachStdin: true,
AttachStdout: true,
Cmd: splitCmd,
Detach: false,
Tty: true,
}
if err := container.RunExec(dcli, cl, targetContainer.ID, &postHookExecOpts); err != nil {
return err
}
logrus.Infof("succesfully ran %s post-hook command: %s", fullServiceName, rsConfig.postHookCmd)
}
return nil
}

View File

@ -1,12 +1,10 @@
package app package app
import ( import (
"context"
"fmt" "fmt"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/lint"
"coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/recipe"
stack "coopcloud.tech/abra/pkg/upstream/stack" stack "coopcloud.tech/abra/pkg/upstream/stack"
"coopcloud.tech/tagcmp" "coopcloud.tech/tagcmp"
@ -15,24 +13,18 @@ import (
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var appRollbackCommand = cli.Command{ var appRollbackCommand = &cli.Command{
Name: "rollback", Name: "rollback",
Aliases: []string{"rl"},
Usage: "Roll an app back to a previous version", Usage: "Roll an app back to a previous version",
ArgsUsage: "<domain> [<version>]", Aliases: []string{"r", "downgrade"},
ArgsUsage: "<app>",
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.ForceFlag, internal.ForceFlag,
internal.ChaosFlag, internal.ChaosFlag,
internal.NoDomainChecksFlag,
internal.DontWaitConvergeFlag,
internal.OfflineFlag,
}, },
Before: internal.SubCommandBefore,
Description: ` Description: `
This command rolls an app back to a previous version if one exists. This command rolls an app back to a previous version if one exists.
@ -40,108 +32,57 @@ You may pass "--force/-f" to downgrade to the same version again. This can be
useful if the container runtime has gotten into a weird state. useful if the container runtime has gotten into a weird state.
This action could be destructive, please ensure you have a copy of your app This action could be destructive, please ensure you have a copy of your app
data beforehand. data beforehand - see "abra app backup <app>" for more.
Chas mode ("--chaos") will deploy your local checkout of a recipe as-is, Chas mode ("--chaos") will deploy your local checkout of a recipe as-is,
including unstaged changes and can be useful for live hacking and testing new including unstaged changes and can be useful for live hacking and testing new
recipes. recipes.
`, `,
BashComplete: autocomplete.AppNameComplete, BashComplete: func(c *cli.Context) {
appNames, err := config.GetAppNames()
if err != nil {
logrus.Warn(err)
}
if c.NArg() > 0 {
return
}
for _, a := range appNames {
fmt.Println(a)
}
},
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
stackName := app.StackName() stackName := app.StackName()
if err := recipe.EnsureExists(app.Recipe); err != nil {
logrus.Fatal(err)
}
if !internal.Chaos {
if err := recipe.EnsureIsClean(app.Recipe); err != nil {
logrus.Fatal(err)
}
if !internal.Offline {
if err := recipe.EnsureUpToDate(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
if err := recipe.EnsureLatest(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
r, err := recipe.Get(app.Recipe, internal.Offline)
if err != nil {
logrus.Fatal(err)
}
if err := lint.LintForErrors(r); err != nil {
logrus.Fatal(err)
}
cl, err := client.New(app.Server) cl, err := client.New(app.Server)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Debugf("checking whether %s is already deployed", stackName) logrus.Debugf("checking whether '%s' is already deployed", stackName)
isDeployed, deployedVersion, err := stack.IsDeployed(context.Background(), cl, stackName) isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, stackName)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if !isDeployed { if !isDeployed {
logrus.Fatalf("%s is not deployed?", app.Name) logrus.Fatalf("'%s' is not deployed?", app.Name)
} }
catl, err := recipe.ReadRecipeCatalogue(internal.Offline) versions, err := catalogue.GetRecipeCatalogueVersions(app.Type)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
versions, err := recipe.GetRecipeCatalogueVersions(app.Recipe, catl)
if err != nil {
logrus.Fatal(err)
}
if len(versions) == 0 && !internal.Chaos {
logrus.Warn("no published versions in catalogue, trying local recipe repository")
recipeVersions, err := recipe.GetRecipeVersions(app.Recipe, internal.Offline)
if err != nil {
logrus.Warn(err)
}
for _, recipeVersion := range recipeVersions {
for version := range recipeVersion {
versions = append(versions, version)
}
}
}
var availableDowngrades []string var availableDowngrades []string
if deployedVersion == "unknown" { if deployedVersion == "" {
deployedVersion = "unknown"
availableDowngrades = versions availableDowngrades = versions
logrus.Warnf("failed to determine deployed version of %s", app.Name) logrus.Warnf("failed to determine version of deployed '%s'", app.Name)
} }
specificVersion := c.Args().Get(1) if deployedVersion != "unknown" && !internal.Chaos {
if specificVersion != "" {
parsedDeployedVersion, err := tagcmp.Parse(deployedVersion)
if err != nil {
logrus.Fatal(err)
}
parsedSpecificVersion, err := tagcmp.Parse(specificVersion)
if err != nil {
logrus.Fatal(err)
}
if parsedSpecificVersion.IsGreaterThan(parsedDeployedVersion) || parsedSpecificVersion.Equals(parsedDeployedVersion) {
logrus.Fatalf("%s is not a downgrade for %s?", deployedVersion, specificVersion)
}
availableDowngrades = append(availableDowngrades, specificVersion)
}
if deployedVersion != "unknown" && !internal.Chaos && specificVersion == "" {
for _, version := range versions { for _, version := range versions {
parsedDeployedVersion, err := tagcmp.Parse(deployedVersion) parsedDeployedVersion, err := tagcmp.Parse(deployedVersion)
if err != nil { if err != nil {
@ -151,26 +92,30 @@ recipes.
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if parsedVersion.IsLessThan(parsedDeployedVersion) && !(parsedVersion.Equals(parsedDeployedVersion)) { if parsedVersion != parsedDeployedVersion && parsedVersion.IsLessThan(parsedDeployedVersion) {
availableDowngrades = append(availableDowngrades, version) availableDowngrades = append(availableDowngrades, version)
} }
} }
if len(availableDowngrades) == 0 && !internal.Force { if len(availableDowngrades) == 0 {
logrus.Info("no available downgrades, you're on oldest ✌️") logrus.Fatal("no available downgrades, you're on latest")
return nil
} }
} }
// FIXME: jeezus golang why do you not have a list reverse function
for i, j := 0, len(availableDowngrades)-1; i < j; i, j = i+1, j-1 {
availableDowngrades[i], availableDowngrades[j] = availableDowngrades[j], availableDowngrades[i]
}
var chosenDowngrade string var chosenDowngrade string
if len(availableDowngrades) > 0 && !internal.Chaos { if !internal.Chaos {
if internal.Force || internal.NoInput || specificVersion != "" { if internal.Force {
chosenDowngrade = availableDowngrades[len(availableDowngrades)-1] chosenDowngrade = availableDowngrades[0]
logrus.Debugf("choosing %s as version to downgrade to (--force/--no-input)", chosenDowngrade) logrus.Debugf("choosing '%s' as version to downgrade to (--force)", chosenDowngrade)
} else { } else {
prompt := &survey.Select{ prompt := &survey.Select{
Message: fmt.Sprintf("Please select a downgrade (current version: %s):", deployedVersion), Message: fmt.Sprintf("Please select a downgrade (current version: '%s'):", deployedVersion),
Options: internal.ReverseStringList(availableDowngrades), Options: availableDowngrades,
} }
if err := survey.AskOne(prompt, &chosenDowngrade); err != nil { if err := survey.AskOne(prompt, &chosenDowngrade); err != nil {
return err return err
@ -179,7 +124,7 @@ recipes.
} }
if !internal.Chaos { if !internal.Chaos {
if err := recipe.EnsureVersion(app.Recipe, chosenDowngrade); err != nil { if err := recipe.EnsureVersion(app.Type, chosenDowngrade); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} }
@ -187,13 +132,13 @@ recipes.
if internal.Chaos { if internal.Chaos {
logrus.Warn("chaos mode engaged") logrus.Warn("chaos mode engaged")
var err error var err error
chosenDowngrade, err = recipe.ChaosVersion(app.Recipe) chosenDowngrade, err = recipe.ChaosVersion(app.Type)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} }
abraShPath := fmt.Sprintf("%s/%s/%s", config.RECIPES_DIR, app.Recipe, "abra.sh") abraShPath := fmt.Sprintf("%s/%s/%s", config.APPS_DIR, app.Type, "abra.sh")
abraShEnv, err := config.ReadAbraShEnvVars(abraShPath) abraShEnv, err := config.ReadAbraShEnvVars(abraShPath)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
@ -202,7 +147,7 @@ recipes.
app.Env[k] = v app.Env[k] = v
} }
composeFiles, err := config.GetAppComposeFiles(app.Recipe, app.Env) composeFiles, err := config.GetAppComposeFiles(app.Type, app.Env)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -216,18 +161,14 @@ recipes.
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
config.ExposeAllEnv(stackName, compose, app.Env)
config.SetRecipeLabel(compose, stackName, app.Recipe)
config.SetChaosLabel(compose, stackName, internal.Chaos)
config.SetChaosVersionLabel(compose, stackName, chosenDowngrade)
config.SetUpdateLabel(compose, stackName, app.Env)
// NOTE(d1): no release notes implemeneted for rolling back if !internal.Force {
if err := internal.NewVersionOverview(app, deployedVersion, chosenDowngrade, ""); err != nil { if err := internal.NewVersionOverview(app, deployedVersion, chosenDowngrade); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
}
if err := stack.RunDeploy(cl, deployOpts, compose, stackName, internal.DontWaitConverge); err != nil { if err := stack.RunDeploy(cl, deployOpts, compose); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }

View File

@ -1,55 +1,51 @@
package app package app
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
containerPkg "coopcloud.tech/abra/pkg/container" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/upstream/container" "coopcloud.tech/abra/pkg/upstream/container"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var user string var user string
var userFlag = &cli.StringFlag{ var userFlag = &cli.StringFlag{
Name: "user, u", Name: "user",
Value: "", Value: "",
Destination: &user, Destination: &user,
} }
var noTTY bool var noTTY bool
var noTTYFlag = &cli.BoolFlag{ var noTTYFlag = &cli.BoolFlag{
Name: "no-tty, t", Name: "no-tty",
Value: false,
Destination: &noTTY, Destination: &noTTY,
} }
var appRunCommand = cli.Command{ var appRunCommand = &cli.Command{
Name: "run", Name: "run",
Aliases: []string{"r"},
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.DebugFlag,
noTTYFlag, noTTYFlag,
userFlag, userFlag,
}, },
Before: internal.SubCommandBefore, Aliases: []string{"r"},
ArgsUsage: "<domain> <service> <args>...", ArgsUsage: "<service> <args>...",
Usage: "Run a command in a service container", Usage: "Run a command in a service container",
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
if len(c.Args()) < 2 { if c.Args().Len() < 2 {
internal.ShowSubcommandHelpAndError(c, errors.New("no <service> provided?")) internal.ShowSubcommandHelpAndError(c, errors.New("no <service> provided?"))
} }
if len(c.Args()) < 3 { if c.Args().Len() < 3 {
internal.ShowSubcommandHelpAndError(c, errors.New("no <args> provided?")) internal.ShowSubcommandHelpAndError(c, errors.New("no <args> provided?"))
} }
@ -59,16 +55,23 @@ var appRunCommand = cli.Command{
} }
serviceName := c.Args().Get(1) serviceName := c.Args().Get(1)
stackAndServiceName := fmt.Sprintf("^%s_%s", app.StackName(), serviceName) stackAndServiceName := fmt.Sprintf("%s_%s", app.StackName(), serviceName)
filters := filters.NewArgs() filters := filters.NewArgs()
filters.Add("name", stackAndServiceName) filters.Add("name", stackAndServiceName)
targetContainer, err := containerPkg.GetContainer(context.Background(), cl, filters, false) containers, err := cl.ContainerList(c.Context, types.ContainerListOptions{Filters: filters})
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
cmd := c.Args()[2:] if len(containers) == 0 {
logrus.Fatalf("no containers matching '%s' found?", stackAndServiceName)
}
if len(containers) > 1 {
logrus.Fatalf("expected 1 container matching '%s' but got %d", stackAndServiceName, len(containers))
}
cmd := c.Args().Slice()[2:]
execCreateOpts := types.ExecConfig{ execCreateOpts := types.ExecConfig{
AttachStderr: true, AttachStderr: true,
AttachStdin: true, AttachStdin: true,
@ -85,16 +88,41 @@ var appRunCommand = cli.Command{
execCreateOpts.Tty = false execCreateOpts.Tty = false
} }
// FIXME: avoid instantiating a new CLI // FIXME: an absolutely monumental hack to instantiate another command-line
// client withing our command-line client so that we pass something down
// the tubes that satisfies the necessary interface requirements. We should
// refactor our vendored container code to not require all this cruft. For
// now, It Works.
dcli, err := command.NewDockerCli() dcli, err := command.NewDockerCli()
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if err := container.RunExec(dcli, cl, targetContainer.ID, &execCreateOpts); err != nil { if err := container.RunExec(dcli, cl, containers[0].ID, &execCreateOpts); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
return nil return nil
}, },
BashComplete: func(c *cli.Context) {
switch c.NArg() {
case 0:
appNames, err := config.GetAppNames()
if err != nil {
logrus.Warn(err)
}
for _, a := range appNames {
fmt.Println(a)
}
case 1:
appName := c.Args().First()
serviceNames, err := config.GetAppServiceNames(appName)
if err != nil {
logrus.Warn(err)
}
for _, s := range serviceNames {
fmt.Println(s)
}
}
},
} }

View File

@ -1,54 +1,41 @@
package app package app
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"os" "os"
"strconv" "strconv"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/secret" "coopcloud.tech/abra/pkg/secret"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
dockerClient "github.com/docker/docker/client" "github.com/docker/docker/api/types/filters"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var allSecrets bool var allSecrets bool
var allSecretsFlag = &cli.BoolFlag{ var allSecretsFlag = &cli.BoolFlag{
Name: "all, a", Name: "all",
Aliases: []string{"A"},
Value: false,
Destination: &allSecrets, Destination: &allSecrets,
Usage: "Generate all secrets", Usage: "Generate all secrets",
} }
var rmAllSecrets bool var appSecretGenerateCommand = &cli.Command{
var rmAllSecretsFlag = &cli.BoolFlag{
Name: "all, a",
Destination: &rmAllSecrets,
Usage: "Remove all secrets",
}
var appSecretGenerateCommand = cli.Command{
Name: "generate", Name: "generate",
Aliases: []string{"g"}, Aliases: []string{"g"},
Usage: "Generate secrets", Usage: "Generate secrets",
ArgsUsage: "<domain> <secret> <version>", ArgsUsage: "<secret> <version>",
Flags: []cli.Flag{ Flags: []cli.Flag{allSecretsFlag, internal.PassFlag},
internal.DebugFlag,
allSecretsFlag,
internal.PassFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
if len(c.Args()) == 1 && !allSecrets { if c.Args().Len() == 1 && !allSecrets {
err := errors.New("missing arguments <secret>/<version> or '--all'") err := errors.New("missing arguments <secret>/<version> or '--all'")
internal.ShowSubcommandHelpAndError(c, err) internal.ShowSubcommandHelpAndError(c, err)
} }
@ -70,28 +57,21 @@ var appSecretGenerateCommand = cli.Command{
parsed := secret.ParseSecretEnvVarName(sec) parsed := secret.ParseSecretEnvVarName(sec)
if secretName == parsed { if secretName == parsed {
secretsToCreate[sec] = secretVersion secretsToCreate[sec] = secretVersion
matches = true
} }
} }
if !matches { if !matches {
logrus.Fatalf("%s doesn't exist in the env config?", secretName) logrus.Fatalf("'%s' doesn't exist in the env config?", secretName)
} }
} }
cl, err := client.New(app.Server) secretVals, err := secret.GenerateSecrets(secretsToCreate, app.StackName(), app.Server)
if err != nil {
logrus.Fatal(err)
}
secretVals, err := secret.GenerateSecrets(cl, secretsToCreate, app.StackName(), app.Server)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if internal.Pass { if internal.Pass {
for name, data := range secretVals { for name, data := range secretVals {
if err := secret.PassInsertSecret(data, name, app.Name, app.Server); err != nil { if err := secret.PassInsertSecret(data, name, app.StackName(), app.Server); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} }
@ -103,7 +83,7 @@ var appSecretGenerateCommand = cli.Command{
} }
tableCol := []string{"name", "value"} tableCol := []string{"name", "value"}
table := formatter.CreateTable(tableCol) table := abraFormatter.CreateTable(tableCol)
for name, val := range secretVals { for name, val := range secretVals {
table.Append([]string{name, val}) table.Append([]string{name, val})
} }
@ -114,17 +94,12 @@ var appSecretGenerateCommand = cli.Command{
}, },
} }
var appSecretInsertCommand = cli.Command{ var appSecretInsertCommand = &cli.Command{
Name: "insert", Name: "insert",
Aliases: []string{"i"}, Aliases: []string{"i"},
Usage: "Insert secret", Usage: "Insert secret",
Flags: []cli.Flag{ Flags: []cli.Flag{internal.PassFlag},
internal.DebugFlag, ArgsUsage: "<app> <secret-name> <version> <data>",
internal.PassFlag,
},
Before: internal.SubCommandBefore,
ArgsUsage: "<domain> <secret-name> <version> <data>",
BashComplete: autocomplete.AppNameComplete,
Description: ` Description: `
This command inserts a secret into an app environment. This command inserts a secret into an app environment.
@ -140,28 +115,21 @@ Example:
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
if len(c.Args()) != 4 { if c.Args().Len() != 4 {
internal.ShowSubcommandHelpAndError(c, errors.New("missing arguments?")) internal.ShowSubcommandHelpAndError(c, errors.New("missing arguments?"))
} }
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
}
name := c.Args().Get(1) name := c.Args().Get(1)
version := c.Args().Get(2) version := c.Args().Get(2)
data := c.Args().Get(3) data := c.Args().Get(3)
secretName := fmt.Sprintf("%s_%s_%s", app.StackName(), name, version) secretName := fmt.Sprintf("%s_%s_%s", app.StackName(), name, version)
if err := client.StoreSecret(cl, secretName, data, app.Server); err != nil { if err := client.StoreSecret(secretName, data, app.Server); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Infof("%s successfully stored on server", secretName)
if internal.Pass { if internal.Pass {
if err := secret.PassInsertSecret(data, name, app.Name, app.Server); err != nil { if err := secret.PassInsertSecret(data, name, app.StackName(), app.Server); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} }
@ -170,54 +138,28 @@ Example:
}, },
} }
// secretRm removes a secret. var appSecretRmCommand = &cli.Command{
func secretRm(cl *dockerClient.Client, app config.App, secretName, parsed string) error {
if err := cl.SecretRemove(context.Background(), secretName); err != nil {
return err
}
logrus.Infof("deleted %s successfully from server", secretName)
if internal.PassRemove {
if err := secret.PassRmSecret(parsed, app.StackName(), app.Server); err != nil {
return err
}
logrus.Infof("deleted %s successfully from local pass store", secretName)
}
return nil
}
var appSecretRmCommand = cli.Command{
Name: "remove", Name: "remove",
Aliases: []string{"rm"},
Usage: "Remove a secret", Usage: "Remove a secret",
Flags: []cli.Flag{ Aliases: []string{"rm"},
internal.DebugFlag, Flags: []cli.Flag{allSecretsFlag, internal.PassFlag},
internal.NoInputFlag, ArgsUsage: "<app> <secret-name>",
rmAllSecretsFlag,
internal.PassRemoveFlag,
},
Before: internal.SubCommandBefore,
ArgsUsage: "<domain> [<secret-name>]",
BashComplete: autocomplete.AppNameComplete,
Description: ` Description: `
This command removes app secrets. This command removes a secret from an app environment.
Example: Example:
abra app secret remove myapp db_pass abra app secret remove myapp db_pass
`, `,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
secrets := secret.ReadSecretEnvVars(app.Env)
if c.Args().Get(1) != "" && rmAllSecrets { if c.Args().Get(1) != "" && allSecrets {
internal.ShowSubcommandHelpAndError(c, errors.New("cannot use '<secret-name>' and '--all' together")) internal.ShowSubcommandHelpAndError(c, errors.New("cannot use '<secret-name>' and '--all' together"))
} }
if c.Args().Get(1) == "" && !rmAllSecrets { if c.Args().Get(1) == "" && !allSecrets {
internal.ShowSubcommandHelpAndError(c, errors.New("no secret(s) specified?")) internal.ShowSubcommandHelpAndError(c, errors.New("no secret(s) specified?"))
} }
@ -226,90 +168,63 @@ Example:
logrus.Fatal(err) logrus.Fatal(err)
} }
filters, err := app.Filters(false, false) filters := filters.NewArgs()
filters.Add("name", app.StackName())
secretList, err := cl.SecretList(c.Context, types.SecretListOptions{Filters: filters})
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
secretList, err := cl.SecretList(context.Background(), types.SecretListOptions{Filters: filters})
if err != nil {
logrus.Fatal(err)
}
remoteSecretNames := make(map[string]bool)
for _, cont := range secretList {
remoteSecretNames[cont.Spec.Annotations.Name] = true
}
match := false
secretToRm := c.Args().Get(1) secretToRm := c.Args().Get(1)
for sec := range secrets { for _, cont := range secretList {
secretName := secret.ParseSecretEnvVarName(sec) secretName := cont.Spec.Annotations.Name
parsed := secret.ParseGeneratedSecretName(secretName, app)
secVal, err := secret.ParseSecretEnvVarValue(secrets[sec]) if allSecrets {
if err != nil { if err := cl.SecretRemove(c.Context, secretName); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if internal.Pass {
secretRemoteName := fmt.Sprintf("%s_%s_%s", app.StackName(), secretName, secVal.Version) if err := secret.PassRmSecret(parsed, app.StackName(), app.Server); err != nil {
if _, ok := remoteSecretNames[secretRemoteName]; ok {
if secretToRm != "" {
if secretName == secretToRm {
if err := secretRm(cl, app, secretRemoteName, secretName); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
return nil
} }
} else { } else {
match = true if parsed == secretToRm {
if err := cl.SecretRemove(c.Context, secretName); err != nil {
if err := secretRm(cl, app, secretRemoteName, secretName); err != nil { logrus.Fatal(err)
}
if internal.Pass {
if err := secret.PassRmSecret(parsed, app.StackName(), app.Server); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} }
} }
} }
if !match && secretToRm != "" {
logrus.Fatalf("%s doesn't exist on server?", secretToRm)
}
if !match {
logrus.Fatal("no secrets to remove?")
} }
return nil return nil
}, },
} }
var appSecretLsCommand = cli.Command{ var appSecretLsCommand = &cli.Command{
Name: "list", Name: "list",
Aliases: []string{"ls"},
Flags: []cli.Flag{
internal.DebugFlag,
},
Before: internal.SubCommandBefore,
Usage: "List all secrets", Usage: "List all secrets",
BashComplete: autocomplete.AppNameComplete, Aliases: []string{"ls"},
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
secrets := secret.ReadSecretEnvVars(app.Env) secrets := secret.ReadSecretEnvVars(app.Env)
tableCol := []string{"Name", "Version", "Generated Name", "Created On Server"} tableCol := []string{"Name", "Version", "Generated Name", "Created On Server"}
table := formatter.CreateTable(tableCol) table := abraFormatter.CreateTable(tableCol)
cl, err := client.New(app.Server) cl, err := client.New(app.Server)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
filters, err := app.Filters(false, false) filters := filters.NewArgs()
if err != nil { filters.Add("name", app.StackName())
logrus.Fatal(err) secretList, err := cl.SecretList(c.Context, types.SecretListOptions{Filters: filters})
}
secretList, err := cl.SecretList(context.Background(), types.SecretListOptions{Filters: filters})
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -334,22 +249,29 @@ var appSecretLsCommand = cli.Command{
table.Append(tableRow) table.Append(tableRow)
} }
if table.NumLines() > 0 {
table.Render() table.Render()
} else {
logrus.Warnf("no secrets stored for %s", app.Name)
}
return nil return nil
}, },
BashComplete: func(c *cli.Context) {
appNames, err := config.GetAppNames()
if err != nil {
logrus.Warn(err)
}
if c.NArg() > 0 {
return
}
for _, a := range appNames {
fmt.Println(a)
}
},
} }
var appSecretCommand = cli.Command{ var appSecretCommand = &cli.Command{
Name: "secret", Name: "secret",
Aliases: []string{"s"}, Aliases: []string{"s"},
Usage: "Manage app secrets", Usage: "Manage app secrets",
ArgsUsage: "<domain>", ArgsUsage: "<command>",
Subcommands: []cli.Command{ Subcommands: []*cli.Command{
appSecretGenerateCommand, appSecretGenerateCommand,
appSecretInsertCommand, appSecretInsertCommand,
appSecretRmCommand, appSecretRmCommand,

View File

@ -1,80 +0,0 @@
package app
import (
"context"
"fmt"
"strings"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/service"
stack "coopcloud.tech/abra/pkg/upstream/stack"
"github.com/docker/docker/api/types"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
)
var appServicesCommand = cli.Command{
Name: "services",
Aliases: []string{"sr"},
Usage: "Display all services of an app",
ArgsUsage: "<domain>",
Flags: []cli.Flag{
internal.DebugFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
}
isDeployed, _, err := stack.IsDeployed(context.Background(), cl, app.StackName())
if err != nil {
logrus.Fatal(err)
}
if !isDeployed {
logrus.Fatalf("%s is not deployed?", app.Name)
}
filters, err := app.Filters(true, true)
if err != nil {
logrus.Fatal(err)
}
containers, err := cl.ContainerList(context.Background(), types.ContainerListOptions{Filters: filters})
if err != nil {
logrus.Fatal(err)
}
tableCol := []string{"service name", "image"}
table := formatter.CreateTable(tableCol)
for _, container := range containers {
var containerNames []string
for _, containerName := range container.Names {
trimmed := strings.TrimPrefix(containerName, "/")
containerNames = append(containerNames, trimmed)
}
serviceShortName := service.ContainerToServiceName(container.Names, app.StackName())
serviceLongName := fmt.Sprintf("%s_%s", app.StackName(), serviceShortName)
tableRow := []string{
serviceLongName,
formatter.RemoveSha(container.Image),
}
table.Append(tableRow)
}
table.Render()
return nil
},
}

View File

@ -1,102 +1,24 @@
package app package app
import ( import (
"context"
"fmt" "fmt"
"time"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
stack "coopcloud.tech/abra/pkg/upstream/stack" stack "coopcloud.tech/abra/pkg/upstream/stack"
"github.com/docker/docker/api/types/filters"
dockerClient "github.com/docker/docker/client"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var prune bool var appUndeployCommand = &cli.Command{
var pruneFlag = &cli.BoolFlag{
Name: "prune, p",
Destination: &prune,
Usage: "Prunes unused containers, networks, and dangling images for an app",
}
// pruneApp runs the equivalent of a "docker system prune" but only filtering
// against resources connected with the app deployment. It is not a system wide
// prune. Volumes are not pruned to avoid unwated data loss.
func pruneApp(c *cli.Context, cl *dockerClient.Client, app config.App) error {
stackName := app.StackName()
ctx := context.Background()
for {
logrus.Debugf("polling for %s stack, waiting to be undeployed...", stackName)
services, err := stack.GetStackServices(ctx, cl, stackName)
if err != nil {
return err
}
if len(services) == 0 {
logrus.Debugf("%s undeployed, moving on with pruning logic", stackName)
time.Sleep(time.Second) // give runtime more time to tear down related state
break
}
time.Sleep(time.Second)
}
pruneFilters := filters.NewArgs()
stackSearch := fmt.Sprintf("%s*", stackName)
pruneFilters.Add("label", stackSearch)
cr, err := cl.ContainersPrune(ctx, pruneFilters)
if err != nil {
return err
}
cntSpaceReclaimed := formatter.ByteCountSI(cr.SpaceReclaimed)
logrus.Infof("containers pruned: %d; space reclaimed: %s", len(cr.ContainersDeleted), cntSpaceReclaimed)
nr, err := cl.NetworksPrune(ctx, pruneFilters)
if err != nil {
return err
}
logrus.Infof("networks pruned: %d", len(nr.NetworksDeleted))
ir, err := cl.ImagesPrune(ctx, pruneFilters)
if err != nil {
return err
}
imgSpaceReclaimed := formatter.ByteCountSI(ir.SpaceReclaimed)
logrus.Infof("images pruned: %d; space reclaimed: %s", len(ir.ImagesDeleted), imgSpaceReclaimed)
return nil
}
var appUndeployCommand = cli.Command{
Name: "undeploy", Name: "undeploy",
Aliases: []string{"un"}, Aliases: []string{"u"},
ArgsUsage: "<domain>",
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
pruneFlag,
},
Before: internal.SubCommandBefore,
Usage: "Undeploy an app", Usage: "Undeploy an app",
BashComplete: autocomplete.AppNameComplete,
Description: ` Description: `
This does not destroy any of the application data. This does not destroy any of the application data. However, you should remain
vigilant, as your swarm installation will consider any previously attached
However, you should remain vigilant, as your swarm installation will consider volumes as eligiblef or pruning once undeployed.
any previously attached volumes as eligible for pruning once undeployed.
Passing "-p/--prune" does not remove those volumes.
`, `,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
@ -107,15 +29,15 @@ Passing "-p/--prune" does not remove those volumes.
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Debugf("checking whether %s is already deployed", stackName) logrus.Debugf("checking whether '%s' is already deployed", stackName)
isDeployed, deployedVersion, err := stack.IsDeployed(context.Background(), cl, stackName) isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, stackName)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if !isDeployed { if !isDeployed {
logrus.Fatalf("%s is not deployed?", app.Name) logrus.Fatalf("'%s' is not deployed?", stackName)
} }
if err := internal.DeployOverview(app, deployedVersion, "continue with undeploy?"); err != nil { if err := internal.DeployOverview(app, deployedVersion, "continue with undeploy?"); err != nil {
@ -123,16 +45,22 @@ Passing "-p/--prune" does not remove those volumes.
} }
rmOpts := stack.Remove{Namespaces: []string{app.StackName()}} rmOpts := stack.Remove{Namespaces: []string{app.StackName()}}
if err := stack.RunRemove(context.Background(), cl, rmOpts); err != nil { if err := stack.RunRemove(c.Context, cl, rmOpts); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if prune {
if err := pruneApp(c, cl, app); err != nil {
logrus.Fatal(err)
}
}
return nil return nil
}, },
BashComplete: func(c *cli.Context) {
appNames, err := config.GetAppNames()
if err != nil {
logrus.Warn(err)
}
if c.NArg() > 0 {
return
}
for _, a := range appNames {
fmt.Println(a)
}
},
} }

View File

@ -1,178 +1,113 @@
package app package app
import ( import (
"context"
"fmt" "fmt"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/lint"
"coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/recipe"
recipePkg "coopcloud.tech/abra/pkg/recipe"
stack "coopcloud.tech/abra/pkg/upstream/stack" stack "coopcloud.tech/abra/pkg/upstream/stack"
"coopcloud.tech/tagcmp" "coopcloud.tech/tagcmp"
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var appUpgradeCommand = cli.Command{ var appUpgradeCommand = &cli.Command{
Name: "upgrade", Name: "upgrade",
Aliases: []string{"up"}, Aliases: []string{"u"},
Usage: "Upgrade an app", Usage: "Upgrade an app",
ArgsUsage: "<domain> [<version>]", ArgsUsage: "<app>",
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.ForceFlag, internal.ForceFlag,
internal.ChaosFlag, internal.ChaosFlag,
internal.NoDomainChecksFlag,
internal.DontWaitConvergeFlag,
internal.OfflineFlag,
}, },
Before: internal.SubCommandBefore,
Description: ` Description: `
Upgrade an app. You can use it to choose and roll out a new upgrade to an This command supports upgrading an app. You can use it to choose and roll out a
existing app. new upgrade to an existing app.
This command specifically supports incrementing the version of running apps, as This command specifically supports changing the version of running apps, as
opposed to "abra app deploy <domain>" which will not change the version of a opposed to "abra app deploy <app>" which will not change the version of a
deployed app. deployed app.
You may pass "--force/-f" to upgrade to the same version again. This can be You may pass "--force/-f" to upgrade to the same version again. This can be
useful if the container runtime has gotten into a weird state. useful if the container runtime has gotten into a weird state.
This action could be destructive, please ensure you have a copy of your app This action could be destructive, please ensure you have a copy of your app
data beforehand. data beforehand - see "abra app backup <app>" for more.
Chas mode ("--chaos") will deploy your local checkout of a recipe as-is, Chas mode ("--chaos") will deploy your local checkout of a recipe as-is,
including unstaged changes and can be useful for live hacking and testing new including unstaged changes and can be useful for live hacking and testing new
recipes. recipes.
`, `,
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
stackName := app.StackName() stackName := app.StackName()
if !internal.Chaos {
if err := recipe.EnsureIsClean(app.Recipe); err != nil {
logrus.Fatal(err)
}
if !internal.Offline {
if err := recipe.EnsureUpToDate(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
if err := recipe.EnsureLatest(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
recipe, err := recipePkg.Get(app.Recipe, internal.Offline)
if err != nil {
logrus.Fatal(err)
}
if err := lint.LintForErrors(recipe); err != nil {
logrus.Fatal(err)
}
logrus.Debugf("checking whether %s is already deployed", stackName)
cl, err := client.New(app.Server) cl, err := client.New(app.Server)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
isDeployed, deployedVersion, err := stack.IsDeployed(context.Background(), cl, stackName) logrus.Debugf("checking whether '%s' is already deployed", stackName)
isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, stackName)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if !isDeployed { if !isDeployed {
logrus.Fatalf("%s is not deployed?", app.Name) logrus.Fatalf("'%s' is not deployed?", app.Name)
} }
catl, err := recipePkg.ReadRecipeCatalogue(internal.Offline) versions, err := catalogue.GetRecipeCatalogueVersions(app.Type)
if err != nil {
logrus.Fatal(err)
}
versions, err := recipePkg.GetRecipeCatalogueVersions(app.Recipe, catl)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if len(versions) == 0 && !internal.Chaos { if len(versions) == 0 && !internal.Chaos {
logrus.Warn("no published versions in catalogue, trying local recipe repository") logrus.Fatalf("no versions available '%s' in recipe catalogue?", app.Type)
recipeVersions, err := recipePkg.GetRecipeVersions(app.Recipe, internal.Offline)
if err != nil {
logrus.Warn(err)
}
for _, recipeVersion := range recipeVersions {
for version := range recipeVersion {
versions = append(versions, version)
}
}
} }
var availableUpgrades []string var availableUpgrades []string
if deployedVersion == "unknown" { if deployedVersion == "" {
deployedVersion = "unknown"
availableUpgrades = versions availableUpgrades = versions
logrus.Warnf("failed to determine deployed version of %s", app.Name) logrus.Warnf("failed to determine version of deployed '%s'", app.Name)
} }
specificVersion := c.Args().Get(1) if deployedVersion != "unknown" && !internal.Chaos {
if specificVersion != "" {
parsedDeployedVersion, err := tagcmp.Parse(deployedVersion)
if err != nil {
logrus.Fatal(err)
}
parsedSpecificVersion, err := tagcmp.Parse(specificVersion)
if err != nil {
logrus.Fatal(err)
}
if parsedSpecificVersion.IsLessThan(parsedDeployedVersion) || parsedSpecificVersion.Equals(parsedDeployedVersion) {
logrus.Fatalf("%s is not an upgrade for %s?", deployedVersion, specificVersion)
}
availableUpgrades = append(availableUpgrades, specificVersion)
}
parsedDeployedVersion, err := tagcmp.Parse(deployedVersion)
if err != nil {
logrus.Fatal(err)
}
if deployedVersion != "unknown" && !internal.Chaos && specificVersion == "" {
for _, version := range versions { for _, version := range versions {
parsedDeployedVersion, err := tagcmp.Parse(deployedVersion)
if err != nil {
logrus.Fatal(err)
}
parsedVersion, err := tagcmp.Parse(version) parsedVersion, err := tagcmp.Parse(version)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if parsedVersion.IsGreaterThan(parsedDeployedVersion) && !(parsedVersion.Equals(parsedDeployedVersion)) { if parsedVersion.IsGreaterThan(parsedDeployedVersion) {
availableUpgrades = append(availableUpgrades, version) availableUpgrades = append(availableUpgrades, version)
} }
} }
if len(availableUpgrades) == 0 && !internal.Force { if len(availableUpgrades) == 0 && !internal.Force {
logrus.Infof("no available upgrades, you're on latest (%s) ✌️", deployedVersion) logrus.Fatal("no available upgrades, you're on latest")
return nil availableUpgrades = versions
} }
} }
var chosenUpgrade string var chosenUpgrade string
if len(availableUpgrades) > 0 && !internal.Chaos { if len(availableUpgrades) > 0 && !internal.Chaos {
if internal.Force || internal.NoInput || specificVersion != "" { if internal.Force {
chosenUpgrade = availableUpgrades[len(availableUpgrades)-1] chosenUpgrade = availableUpgrades[len(availableUpgrades)-1]
logrus.Debugf("choosing %s as version to upgrade to", chosenUpgrade) logrus.Debugf("choosing '%s' as version to upgrade to", chosenUpgrade)
} else { } else {
prompt := &survey.Select{ prompt := &survey.Select{
Message: fmt.Sprintf("Please select an upgrade (current version: %s):", deployedVersion), Message: fmt.Sprintf("Please select an upgrade (current version: '%s'):", deployedVersion),
Options: internal.ReverseStringList(availableUpgrades), Options: availableUpgrades,
} }
if err := survey.AskOne(prompt, &chosenUpgrade); err != nil { if err := survey.AskOne(prompt, &chosenUpgrade); err != nil {
return err return err
@ -180,38 +115,8 @@ recipes.
} }
} }
if internal.Force && chosenUpgrade == "" {
logrus.Warnf("%s is already upgraded to latest but continuing (--force/--chaos)", app.Name)
chosenUpgrade = deployedVersion
}
// if release notes written after git tag published, read them before we
// check out the tag and then they'll appear to be missing. this covers
// when we obviously will forget to write release notes before publishing
var releaseNotes string
for _, version := range versions {
parsedVersion, err := tagcmp.Parse(version)
if err != nil {
logrus.Fatal(err)
}
parsedChosenUpgrade, err := tagcmp.Parse(chosenUpgrade)
if err != nil {
logrus.Fatal(err)
}
if !(parsedVersion.Equals(parsedDeployedVersion)) && parsedVersion.IsLessThan(parsedChosenUpgrade) {
note, err := internal.GetReleaseNotes(app.Recipe, version)
if err != nil {
return err
}
if note != "" {
releaseNotes += fmt.Sprintf("%s\n", note)
}
}
}
if !internal.Chaos { if !internal.Chaos {
if err := recipePkg.EnsureVersion(app.Recipe, chosenUpgrade); err != nil { if err := recipe.EnsureVersion(app.Type, chosenUpgrade); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} }
@ -219,13 +124,13 @@ recipes.
if internal.Chaos { if internal.Chaos {
logrus.Warn("chaos mode engaged") logrus.Warn("chaos mode engaged")
var err error var err error
chosenUpgrade, err = recipePkg.ChaosVersion(app.Recipe) chosenUpgrade, err = recipe.ChaosVersion(app.Type)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} }
abraShPath := fmt.Sprintf("%s/%s/%s", config.RECIPES_DIR, app.Recipe, "abra.sh") abraShPath := fmt.Sprintf("%s/%s/%s", config.APPS_DIR, app.Type, "abra.sh")
abraShEnv, err := config.ReadAbraShEnvVars(abraShPath) abraShEnv, err := config.ReadAbraShEnvVars(abraShPath)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
@ -234,7 +139,7 @@ recipes.
app.Env[k] = v app.Env[k] = v
} }
composeFiles, err := config.GetAppComposeFiles(app.Recipe, app.Env) composeFiles, err := config.GetAppComposeFiles(app.Type, app.Env)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -248,34 +153,27 @@ recipes.
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
config.ExposeAllEnv(stackName, compose, app.Env)
config.SetRecipeLabel(compose, stackName, app.Recipe)
config.SetChaosLabel(compose, stackName, internal.Chaos)
config.SetChaosVersionLabel(compose, stackName, chosenUpgrade)
config.SetUpdateLabel(compose, stackName, app.Env)
if err := internal.NewVersionOverview(app, deployedVersion, chosenUpgrade, releaseNotes); err != nil { if err := internal.NewVersionOverview(app, deployedVersion, chosenUpgrade); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
stack.WaitTimeout, err = config.GetTimeoutFromLabel(compose, stackName) if err := stack.RunDeploy(cl, deployOpts, compose); err != nil {
if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Debugf("set waiting timeout to %d s", stack.WaitTimeout)
if err := stack.RunDeploy(cl, deployOpts, compose, stackName, internal.DontWaitConverge); err != nil {
logrus.Fatal(err)
}
postDeployCmds, ok := app.Env["POST_UPGRADE_CMDS"]
if ok && !internal.DontWaitConverge {
logrus.Debugf("run the following post-deploy commands: %s", postDeployCmds)
if err := internal.PostCmds(cl, app, postDeployCmds); err != nil {
logrus.Fatalf("attempting to run post deploy commands, saw: %s", err)
}
}
return nil return nil
}, },
BashComplete: func(c *cli.Context) {
appNames, err := config.GetAppNames()
if err != nil {
logrus.Warn(err)
}
if c.NArg() > 0 {
return
}
for _, a := range appNames {
fmt.Println(a)
}
},
} }

View File

@ -1,59 +1,43 @@
package app package app
import ( import (
"context" "fmt"
"sort" "strings"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/upstream/stack" "coopcloud.tech/abra/pkg/upstream/stack"
"github.com/docker/distribution/reference" "github.com/docker/distribution/reference"
"github.com/olekukonko/tablewriter"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
func sortServiceByName(versions [][]string) func(i, j int) bool {
return func(i, j int) bool {
// NOTE(d1): corresponds to the `tableCol` definition below
if versions[i][1] == "app" {
return true
}
return versions[i][1] < versions[j][1]
}
}
// getImagePath returns the image name // getImagePath returns the image name
func getImagePath(image string) (string, error) { func getImagePath(image string) (string, error) {
img, err := reference.ParseNormalizedNamed(image) img, err := reference.ParseNormalizedNamed(image)
if err != nil { if err != nil {
return "", err return "", err
} }
path := reference.Path(img) path := reference.Path(img)
if strings.Contains(path, "library") {
path = formatter.StripTagMeta(path) path = strings.Split(path, "/")[1]
}
logrus.Debugf("parsed %s from %s", path, image) logrus.Debugf("parsed '%s' from '%s'", path, image)
return path, nil return path, nil
} }
var appVersionCommand = cli.Command{ var appVersionCommand = &cli.Command{
Name: "version", Name: "version",
Aliases: []string{"v"}, Aliases: []string{"v"},
ArgsUsage: "<domain>", Usage: "Show app versions",
Flags: []cli.Flag{ Description: `
internal.DebugFlag, This command shows all information about versioning related to a deployed app.
internal.NoInputFlag, This includes the individual image names, tags and digests. But also the Co-op
internal.OfflineFlag, Cloud recipe version.
}, `,
Before: internal.SubCommandBefore,
Usage: "Show version info of a deployed app",
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
stackName := app.StackName() stackName := app.StackName()
@ -63,27 +47,27 @@ var appVersionCommand = cli.Command{
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Debugf("checking whether %s is already deployed", stackName) logrus.Debugf("checking whether '%s' is already deployed", stackName)
isDeployed, deployedVersion, err := stack.IsDeployed(context.Background(), cl, stackName) isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, stackName)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if deployedVersion == "" {
logrus.Fatalf("failed to determine version of deployed '%s'", app.Name)
}
if !isDeployed { if !isDeployed {
logrus.Fatalf("%s is not deployed?", app.Name) logrus.Fatalf("'%s' is not deployed?", app.Name)
} }
if deployedVersion == "unknown" { recipeMeta, err := catalogue.GetRecipeMeta(app.Type)
logrus.Fatalf("failed to determine version of deployed %s", app.Name)
}
recipeMeta, err := recipe.GetRecipeMeta(app.Recipe, internal.Offline)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
versionsMeta := make(map[string]recipe.ServiceMeta) versionsMeta := make(map[string]catalogue.ServiceMeta)
for _, recipeVersion := range recipeMeta.Versions { for _, recipeVersion := range recipeMeta.Versions {
if currentVersion, exists := recipeVersion[deployedVersion]; exists { if currentVersion, exists := recipeVersion[deployedVersion]; exists {
versionsMeta = currentVersion versionsMeta = currentVersion
@ -91,27 +75,30 @@ var appVersionCommand = cli.Command{
} }
if len(versionsMeta) == 0 { if len(versionsMeta) == 0 {
logrus.Fatalf("could not retrieve deployed version (%s) from recipe catalogue?", deployedVersion) logrus.Fatalf("PANIC: could not retrieve deployed version ('%s') from recipe catalogue?", deployedVersion)
} }
tableCol := []string{"version", "service", "image", "tag"} tableCol := []string{"name", "image", "version", "tag", "digest"}
table := formatter.CreateTable(tableCol) table := abraFormatter.CreateTable(tableCol)
var versions [][]string
for serviceName, versionMeta := range versionsMeta { for serviceName, versionMeta := range versionsMeta {
versions = append(versions, []string{deployedVersion, serviceName, versionMeta.Image, versionMeta.Tag}) table.Append([]string{serviceName, versionMeta.Image, deployedVersion, versionMeta.Tag, versionMeta.Digest})
} }
sort.Slice(versions, sortServiceByName(versions))
for _, version := range versions {
table.Append(version)
}
table.SetAutoMergeCellsByColumnIndex([]int{0})
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.Render() table.Render()
return nil return nil
}, },
BashComplete: func(c *cli.Context) {
appNames, err := config.GetAppNames()
if err != nil {
logrus.Warn(err)
}
if c.NArg() > 0 {
return
}
for _, a := range appNames {
fmt.Println(a)
}
},
} }

View File

@ -1,156 +1,105 @@
package app package app
import ( import (
"context" "fmt"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/upstream/stack"
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var appVolumeListCommand = cli.Command{ var appVolumeListCommand = &cli.Command{
Name: "list", Name: "list",
Aliases: []string{"ls"},
ArgsUsage: "<domain>",
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
},
Before: internal.SubCommandBefore,
Usage: "List volumes associated with an app", Usage: "List volumes associated with an app",
BashComplete: autocomplete.AppNameComplete, Aliases: []string{"ls"},
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
cl, err := client.New(app.Server) volumeList, err := client.GetVolumes(c.Context, app.Server, app.Name)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
filters, err := app.Filters(false, true) table := abraFormatter.CreateTable([]string{"driver", "volume name"})
if err != nil {
logrus.Fatal(err)
}
volumeList, err := client.GetVolumes(cl, context.Background(), app.Server, filters)
if err != nil {
logrus.Fatal(err)
}
table := formatter.CreateTable([]string{"name", "created", "mounted"})
var volTable [][]string var volTable [][]string
for _, volume := range volumeList { for _, volume := range volumeList {
volRow := []string{volume.Name, volume.CreatedAt, volume.Mountpoint} volRow := []string{
volume.Driver,
volume.Name,
}
volTable = append(volTable, volRow) volTable = append(volTable, volRow)
} }
table.AppendBulk(volTable) table.AppendBulk(volTable)
if table.NumLines() > 0 {
table.Render() table.Render()
} else {
logrus.Warnf("no volumes created for %s", app.Name)
}
return nil return nil
}, },
} }
var appVolumeRemoveCommand = cli.Command{ var appVolumeRemoveCommand = &cli.Command{
Name: "remove", Name: "remove",
Usage: "Remove volume(s) associated with an app", Usage: "Remove volume(s) associated with an app",
Description: `
This command supports removing volumes associated with an app. The app in
question must be undeployed before you try to remove volumes. See "abra app
undeploy <domain>" for more.
The command is interactive and will show a multiple select input which allows
you to make a seclection. Use the "?" key to see more help on navigating this
interface.
Passing "--force/-f" will select all volumes for removal. Be careful.
`,
ArgsUsage: "<domain>",
Aliases: []string{"rm"}, Aliases: []string{"rm"},
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.ForceFlag, internal.ForceFlag,
}, },
Before: internal.SubCommandBefore,
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
cl, err := client.New(app.Server) volumeList, err := client.GetVolumes(c.Context, app.Server, app.Name)
if err != nil {
logrus.Fatal(err)
}
isDeployed, _, err := stack.IsDeployed(context.Background(), cl, app.StackName())
if err != nil {
logrus.Fatal(err)
}
if isDeployed {
logrus.Fatalf("%s is still deployed. Run \"abra app undeploy %s\"", app.Name, app.Name)
}
filters, err := app.Filters(false, true)
if err != nil {
logrus.Fatal(err)
}
volumeList, err := client.GetVolumes(cl, context.Background(), app.Server, filters)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
volumeNames := client.GetVolumeNames(volumeList) volumeNames := client.GetVolumeNames(volumeList)
var volumesToRemove []string var volumesToRemove []string
if !internal.Force && !internal.NoInput { if !internal.Force {
volumesPrompt := &survey.MultiSelect{ volumesPrompt := &survey.MultiSelect{
Message: "which volumes do you want to remove?", Message: "which volumes do you want to remove?",
Help: "'x' indicates selected, enter / return to confirm, ctrl-c to exit, vim mode is enabled",
VimMode: true,
Options: volumeNames, Options: volumeNames,
Default: volumeNames, Default: volumeNames,
} }
if err := survey.AskOne(volumesPrompt, &volumesToRemove); err != nil { if err := survey.AskOne(volumesPrompt, &volumesToRemove); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} } else {
if internal.Force || internal.NoInput {
volumesToRemove = volumeNames volumesToRemove = volumeNames
} }
if len(volumesToRemove) > 0 { err = client.RemoveVolumes(c.Context, app.Server, volumesToRemove, internal.Force)
err = client.RemoveVolumes(cl, context.Background(), app.Server, volumesToRemove, internal.Force)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Info("volumes removed successfully") logrus.Info("volumes removed successfully")
} else {
logrus.Info("no volumes removed")
}
return nil return nil
}, },
BashComplete: func(c *cli.Context) {
appNames, err := config.GetAppNames()
if err != nil {
logrus.Warn(err)
}
if c.NArg() > 0 {
return
}
for _, a := range appNames {
fmt.Println(a)
}
},
} }
var appVolumeCommand = cli.Command{ var appVolumeCommand = &cli.Command{
Name: "volume", Name: "volume",
Aliases: []string{"vl"}, Aliases: []string{"vl"},
Usage: "Manage app volumes", Usage: "Manage app volumes",
ArgsUsage: "<domain>", ArgsUsage: "<command>",
Subcommands: []cli.Command{ Subcommands: []*cli.Command{
appVolumeListCommand, appVolumeListCommand,
appVolumeRemoveCommand, appVolumeRemoveCommand,
}, },

121
cli/autocomplete.go Normal file
View File

@ -0,0 +1,121 @@
package cli
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"path"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/config"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
// downloadFile downloads a file brah
func downloadFile(filepath string, url string) (err error) {
out, err := os.Create(filepath)
if err != nil {
return err
}
defer out.Close()
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bad status: %s", resp.Status)
}
_, err = io.Copy(out, resp.Body)
if err != nil {
return err
}
return nil
}
// AutoCompleteCommand helps people set up auto-complete in their shells
var AutoCompleteCommand = &cli.Command{
Name: "autocomplete",
Usage: "Help set up shell autocompletion",
Aliases: []string{"ac"},
Description: `
This command helps set up autocompletion in your shell by downloading the
relevant autocompletion files and laying out what additional information must
be loaded.
Example:
abra autocomplete bash
Supported shells are as follows:
fish
zsh
bash
`,
ArgsUsage: "<shell>",
Action: func(c *cli.Context) error {
shellType := c.Args().First()
if shellType == "" {
internal.ShowSubcommandHelpAndError(c, errors.New("no shell provided"))
}
supportedShells := map[string]bool{
"bash": true,
"zsh": true,
"fish": true,
}
if _, ok := supportedShells[shellType]; !ok {
logrus.Fatalf("%s is not a supported shell right now, sorry", shellType)
}
if shellType == "fish" {
shellType = "zsh" // handled the same on the autocompletion side
}
autocompletionDir := path.Join(config.ABRA_DIR, "autocompletion")
if err := os.Mkdir(autocompletionDir, 0755); err != nil {
if !os.IsExist(err) {
logrus.Fatal(err)
}
logrus.Debugf("'%s' already created, moving on...", autocompletionDir)
}
autocompletionFile := path.Join(config.ABRA_DIR, "autocompletion", shellType)
if _, err := os.Stat(autocompletionFile); err != nil && os.IsNotExist(err) {
url := fmt.Sprintf("https://git.coopcloud.tech/coop-cloud/abra/raw/branch/main/scripts/autocomplete/%s", shellType)
logrus.Infof("fetching %s", url)
if err := downloadFile(autocompletionFile, url); err != nil {
logrus.Fatal(err)
}
}
switch shellType {
case "bash":
fmt.Println(fmt.Sprintf(`
# Run the following commands to install autocompletion
sudo mkdir /etc/bash/completion.d/
sudo cp %s /etc/bash_completion.d/abra
echo "source /etc/bash/completion.d/abra" >> ~/.bashrc
`, autocompletionFile))
case "zsh":
fmt.Println(fmt.Sprintf(`
# Run the following commands to install autocompletion
sudo mkdir /etc/zsh/completion.d/
sudo cp %s /etc/zsh/completion.d/abra
echo "PROG=abra\n_CLI_ZSH_AUTOCOMPLETE_HACK=1\nsource /etc/zsh/completion.d/abra" >> ~/.zshrc
`, autocompletionFile))
}
return nil
},
}

View File

@ -1,228 +1,17 @@
package catalogue package catalogue
import ( import (
"encoding/json" "github.com/urfave/cli/v2"
"fmt"
"io/ioutil"
"path"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
gitPkg "coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/recipe"
"github.com/go-git/go-git/v5"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
) )
var catalogueGenerateCommand = cli.Command{
Name: "generate",
Aliases: []string{"g"},
Usage: "Generate the recipe catalogue",
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.PublishFlag,
internal.DryFlag,
internal.SkipUpdatesFlag,
internal.ChaosFlag,
internal.OfflineFlag,
},
Before: internal.SubCommandBefore,
Description: `
Generate a new copy of the recipe catalogue which can be found on:
https://recipes.coopcloud.tech (website that humans read)
https://recipes.coopcloud.tech/recipes.json (JSON that Abra reads)
It polls the entire git.coopcloud.tech/coop-cloud/... recipe repository
listing, parses README.md and git tags to produce recipe metadata which is
loaded into the catalogue JSON file.
It is possible to generate new metadata for a single recipe by passing
<recipe>. The existing local catalogue will be updated, not overwritten.
It is quite easy to get rate limited by Docker Hub when running this command.
If you have a Hub account you can have Abra log you in to avoid this. Pass
"--user" and "--pass".
Push your new release to git.coopcloud.tech with "-p/--publish". This requires
that you have permission to git push to these repositories and have your SSH
keys configured on your account.
`,
ArgsUsage: "[<recipe>]",
BashComplete: autocomplete.RecipeNameComplete,
Action: func(c *cli.Context) error {
recipeName := c.Args().First()
if recipeName != "" {
internal.ValidateRecipe(c)
}
if !internal.Chaos {
if err := catalogue.EnsureIsClean(); err != nil {
logrus.Fatal(err)
}
}
repos, err := recipe.ReadReposMetadata()
if err != nil {
logrus.Fatal(err)
}
var barLength int
var logMsg string
if recipeName != "" {
barLength = 1
logMsg = fmt.Sprintf("ensuring %v recipe is cloned & up-to-date", barLength)
} else {
barLength = len(repos)
logMsg = fmt.Sprintf("ensuring %v recipes are cloned & up-to-date, this could take some time...", barLength)
}
if !internal.SkipUpdates {
logrus.Warn(logMsg)
if err := recipe.UpdateRepositories(repos, recipeName); err != nil {
logrus.Fatal(err)
}
}
catl := make(recipe.RecipeCatalogue)
catlBar := formatter.CreateProgressbar(barLength, "generating catalogue metadata...")
for _, recipeMeta := range repos {
if recipeName != "" && recipeName != recipeMeta.Name {
catlBar.Add(1)
continue
}
if _, exists := catalogue.CatalogueSkipList[recipeMeta.Name]; exists {
catlBar.Add(1)
continue
}
versions, err := recipe.GetRecipeVersions(recipeMeta.Name, internal.Offline)
if err != nil {
logrus.Warn(err)
}
features, category, err := recipe.GetRecipeFeaturesAndCategory(recipeMeta.Name)
if err != nil {
logrus.Warn(err)
}
catl[recipeMeta.Name] = recipe.RecipeMeta{
Name: recipeMeta.Name,
Repository: recipeMeta.CloneURL,
SSHURL: recipeMeta.SSHURL,
Icon: recipeMeta.AvatarURL,
DefaultBranch: recipeMeta.DefaultBranch,
Description: recipeMeta.Description,
Website: recipeMeta.Website,
Versions: versions,
Category: category,
Features: features,
}
catlBar.Add(1)
}
recipesJSON, err := json.MarshalIndent(catl, "", " ")
if err != nil {
logrus.Fatal(err)
}
if recipeName == "" {
if err := ioutil.WriteFile(config.RECIPES_JSON, recipesJSON, 0764); err != nil {
logrus.Fatal(err)
}
} else {
catlFS, err := recipe.ReadRecipeCatalogue(internal.Offline)
if err != nil {
logrus.Fatal(err)
}
catlFS[recipeName] = catl[recipeName]
updatedRecipesJSON, err := json.MarshalIndent(catlFS, "", " ")
if err != nil {
logrus.Fatal(err)
}
if err := ioutil.WriteFile(config.RECIPES_JSON, updatedRecipesJSON, 0764); err != nil {
logrus.Fatal(err)
}
}
logrus.Infof("generated new recipe catalogue in %s", config.RECIPES_JSON)
cataloguePath := path.Join(config.ABRA_DIR, "catalogue")
if internal.Publish {
isClean, err := gitPkg.IsClean(cataloguePath)
if err != nil {
logrus.Fatal(err)
}
if isClean {
if !internal.Dry {
logrus.Fatalf("no changes discovered in %s, nothing to publish?", cataloguePath)
}
}
msg := "chore: publish new catalogue release changes"
if err := gitPkg.Commit(cataloguePath, "**.json", msg, internal.Dry); err != nil {
logrus.Fatal(err)
}
repo, err := git.PlainOpen(cataloguePath)
if err != nil {
logrus.Fatal(err)
}
sshURL := fmt.Sprintf(config.SSH_URL_TEMPLATE, config.CATALOGUE_JSON_REPO_NAME)
if err := gitPkg.CreateRemote(repo, "origin-ssh", sshURL, internal.Dry); err != nil {
logrus.Fatal(err)
}
if err := gitPkg.Push(cataloguePath, "origin-ssh", false, internal.Dry); err != nil {
logrus.Fatal(err)
}
}
repo, err := git.PlainOpen(cataloguePath)
if err != nil {
logrus.Fatal(err)
}
head, err := repo.Head()
if err != nil {
logrus.Fatal(err)
}
if !internal.Dry && internal.Publish {
url := fmt.Sprintf("%s/%s/commit/%s", config.REPOS_BASE_URL, config.CATALOGUE_JSON_REPO_NAME, head.Hash())
logrus.Infof("new changes published: %s", url)
}
if internal.Dry {
logrus.Info("dry run: no changes published")
}
return nil
},
}
// CatalogueCommand defines the `abra catalogue` command and sub-commands. // CatalogueCommand defines the `abra catalogue` command and sub-commands.
var CatalogueCommand = cli.Command{ var CatalogueCommand = &cli.Command{
Name: "catalogue", Name: "catalogue",
Usage: "Manage the recipe catalogue", Usage: "Manage the recipe catalogue (for maintainers)",
Aliases: []string{"c"}, Aliases: []string{"c"},
ArgsUsage: "<recipe>", ArgsUsage: "<recipe>",
Description: "This command helps recipe packagers interact with the recipe catalogue", Description: "This command helps recipe packagers interact with the recipe catalogue",
Subcommands: []cli.Command{ Subcommands: []*cli.Command{
catalogueGenerateCommand, catalogueGenerateCommand,
}, },
} }

261
cli/catalogue/generate.go Normal file
View File

@ -0,0 +1,261 @@
package catalogue
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path"
"coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/config"
gitPkg "coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/limit"
"github.com/AlecAivazis/survey/v2"
"github.com/go-git/go-git/v5"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
// CatalogueSkipList is all the repos that are not recipes.
var CatalogueSkipList = map[string]bool{
"abra": true,
"abra-bash": true,
"abra-apps": true,
"abra-aur": true,
"abra-capsul": true,
"abra-gandi": true,
"abra-hetzner": true,
"apps": true,
"aur-abra-git": true,
"auto-apps-json": true,
"auto-mirror": true,
"backup-bot": true,
"backup-bot-two": true,
"coopcloud.tech": true,
"coturn": true,
"docker-cp-deploy": true,
"docker-dind-bats-kcov": true,
"docs.coopcloud.tech": true,
"example": true,
"gardening": true,
"go-abra": true,
"organising": true,
"pyabra": true,
"radicle-seed-node": true,
"stack-ssh-deploy": true,
"swarm-cronjob": true,
"tagcmp": true,
"tyop": true,
}
var commit bool
var commitFlag = &cli.BoolFlag{
Name: "commit",
Usage: "Commits new generated catalogue changes",
Value: false,
Aliases: []string{"c"},
Destination: &commit,
}
var catalogueGenerateCommand = &cli.Command{
Name: "generate",
Aliases: []string{"g"},
Usage: "Generate a new copy of the catalogue",
Flags: []cli.Flag{
internal.PushFlag,
commitFlag,
internal.CommitMessageFlag,
},
Description: `
This command generates a new copy of the recipe catalogue which can be found on:
https://recipes.coopcloud.tech
It polls the entire git.coopcloud.tech/coop-cloud/... recipe repository
listing, parses README and tags to produce recipe metadata and produces a
apps.json file which is placed in your ~/.abra/catalogue/recipes.json.
It is possible to generate new metadata for a single recipe by passing
<recipe>. The existing local catalogue will be updated, not overwritten.
A new catalogue copy can be published to the recipes repository by passing the
"--commit" and "--push" flags. The recipes repository is available here:
https://git.coopcloud.tech/coop-cloud/recipes
`,
ArgsUsage: "[<recipe>]",
Action: func(c *cli.Context) error {
recipeName := c.Args().First()
catalogueDir := path.Join(config.ABRA_DIR, "catalogue")
url := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, "recipes")
if err := gitPkg.Clone(catalogueDir, url); err != nil {
return err
}
repos, err := catalogue.ReadReposMetadata()
if err != nil {
logrus.Fatal(err)
}
logrus.Debugf("ensuring '%v' recipe(s) are locally present and up-to-date", len(repos))
cloneLimiter := limit.New(10)
retrieveBar := formatter.CreateProgressbar(len(repos), "retrieving recipes...")
ch := make(chan string, len(repos))
for _, repoMeta := range repos {
go func(rm catalogue.RepoMeta) {
cloneLimiter.Begin()
defer cloneLimiter.End()
if recipeName != "" && recipeName != rm.Name {
ch <- rm.Name
retrieveBar.Add(1)
return
}
if _, exists := CatalogueSkipList[rm.Name]; exists {
ch <- rm.Name
retrieveBar.Add(1)
return
}
recipeDir := path.Join(config.ABRA_DIR, "apps", rm.Name)
if err := gitPkg.Clone(recipeDir, rm.SSHURL); err != nil {
logrus.Fatal(err)
}
if err := gitPkg.EnsureUpToDate(recipeDir); err != nil {
logrus.Fatal(err)
}
ch <- rm.Name
retrieveBar.Add(1)
}(repoMeta)
}
for range repos {
<-ch // wait for everything
}
catl := make(catalogue.RecipeCatalogue)
catlBar := formatter.CreateProgressbar(len(repos), "generating catalogue...")
for _, recipeMeta := range repos {
if recipeName != "" && recipeName != recipeMeta.Name {
catlBar.Add(1)
continue
}
if _, exists := CatalogueSkipList[recipeMeta.Name]; exists {
catlBar.Add(1)
continue
}
versions, err := catalogue.GetRecipeVersions(recipeMeta.Name)
if err != nil {
logrus.Fatal(err)
}
catl[recipeMeta.Name] = catalogue.RecipeMeta{
Name: recipeMeta.Name,
Repository: recipeMeta.CloneURL,
Icon: recipeMeta.AvatarURL,
DefaultBranch: recipeMeta.DefaultBranch,
Description: recipeMeta.Description,
Website: recipeMeta.Website,
Versions: versions,
// Category: ..., // FIXME: parse & load
// Features: ..., // FIXME: parse & load
}
catlBar.Add(1)
}
recipesJSON, err := json.MarshalIndent(catl, "", " ")
if err != nil {
logrus.Fatal(err)
}
if _, err := os.Stat(config.APPS_JSON); err != nil && os.IsNotExist(err) {
if err := ioutil.WriteFile(config.APPS_JSON, recipesJSON, 0644); err != nil {
logrus.Fatal(err)
}
} else {
if recipeName != "" {
catlFS, err := catalogue.ReadRecipeCatalogue()
if err != nil {
logrus.Fatal(err)
}
catlFS[recipeName] = catl[recipeName]
updatedRecipesJSON, err := json.MarshalIndent(catlFS, "", " ")
if err != nil {
logrus.Fatal(err)
}
if err := ioutil.WriteFile(config.APPS_JSON, updatedRecipesJSON, 0644); err != nil {
logrus.Fatal(err)
}
}
}
cataloguePath := path.Join(config.ABRA_DIR, "catalogue", "recipes.json")
logrus.Infof("generated new recipe catalogue in %s", cataloguePath)
if commit {
repoPath := path.Join(config.ABRA_DIR, "catalogue")
commitRepo, err := git.PlainOpen(repoPath)
if err != nil {
logrus.Fatal(err)
}
commitWorktree, err := commitRepo.Worktree()
if err != nil {
logrus.Fatal(err)
}
if internal.CommitMessage == "" {
prompt := &survey.Input{
Message: "commit message",
Default: "chore: publish new catalogue changes",
}
if err := survey.AskOne(prompt, &internal.CommitMessage); err != nil {
logrus.Fatal(err)
}
}
err = commitWorktree.AddGlob("**.json")
if err != nil {
logrus.Fatal(err)
}
logrus.Debug("staged **.json for commit")
_, err = commitWorktree.Commit(internal.CommitMessage, &git.CommitOptions{})
if err != nil {
logrus.Fatal(err)
}
logrus.Info("changes commited")
if err := commitRepo.Push(&git.PushOptions{}); err != nil {
logrus.Fatal(err)
}
logrus.Info("changes pushed")
}
return nil
},
BashComplete: func(c *cli.Context) {
catl, err := catalogue.ReadRecipeCatalogue()
if err != nil {
logrus.Warn(err)
}
if c.NArg() > 0 {
return
}
for name := range catl {
fmt.Println(name)
}
},
}

View File

@ -2,149 +2,51 @@
package cli package cli
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"os/exec"
"path" "path"
"coopcloud.tech/abra/cli/app" "coopcloud.tech/abra/cli/app"
"coopcloud.tech/abra/cli/catalogue" "coopcloud.tech/abra/cli/catalogue"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/cli/recipe" "coopcloud.tech/abra/cli/recipe"
"coopcloud.tech/abra/cli/record"
"coopcloud.tech/abra/cli/server" "coopcloud.tech/abra/cli/server"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/git" logrusStack "github.com/Gurpartap/logrus-stack"
"coopcloud.tech/abra/pkg/web"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
// AutoCompleteCommand helps people set up auto-complete in their shells // Verbose stores the variable from VerboseFlag.
var AutoCompleteCommand = cli.Command{ var Verbose bool
Name: "autocomplete",
Aliases: []string{"ac"},
Usage: "Configure shell autocompletion (recommended)",
Description: `
Set up auto-completion in your shell by downloading the relevant files and
laying out what additional information must be loaded. Supported shells are as
follows: bash, fish, fizsh & zsh.
Example: // VerboseFlag turns on/off verbose logging down to the INFO level.
var VerboseFlag = &cli.BoolFlag{
abra autocomplete bash Name: "verbose",
`, Aliases: []string{"V"},
ArgsUsage: "<shell>", Value: false,
Flags: []cli.Flag{ Destination: &Verbose,
internal.DebugFlag, Usage: "Show INFO messages",
},
Action: func(c *cli.Context) error {
shellType := c.Args().First()
if shellType == "" {
internal.ShowSubcommandHelpAndError(c, errors.New("no shell provided"))
} }
supportedShells := map[string]bool{ // Debug stores the variable from DebugFlag.
"bash": true, var Debug bool
"zsh": true,
"fizsh": true,
"fish": true,
}
if _, ok := supportedShells[shellType]; !ok { // DebugFlag turns on/off verbose logging down to the DEBUG level.
logrus.Fatalf("%s is not a supported shell right now, sorry", shellType) var DebugFlag = &cli.BoolFlag{
} Name: "debug",
Aliases: []string{"d"},
if shellType == "fizsh" { Value: false,
shellType = "zsh" // handled the same on the autocompletion side Destination: &Debug,
} Usage: "Show DEBUG messages",
autocompletionDir := path.Join(config.ABRA_DIR, "autocompletion")
if err := os.Mkdir(autocompletionDir, 0764); err != nil {
if !os.IsExist(err) {
logrus.Fatal(err)
}
logrus.Debugf("%s already created", autocompletionDir)
}
autocompletionFile := path.Join(config.ABRA_DIR, "autocompletion", shellType)
if _, err := os.Stat(autocompletionFile); err != nil && os.IsNotExist(err) {
url := fmt.Sprintf("https://git.coopcloud.tech/coop-cloud/abra/raw/branch/main/scripts/autocomplete/%s", shellType)
logrus.Infof("fetching %s", url)
if err := web.GetFile(autocompletionFile, url); err != nil {
logrus.Fatal(err)
}
}
switch shellType {
case "bash":
fmt.Println(fmt.Sprintf(`
# Run the following commands to install auto-completion
sudo mkdir /etc/bash_completion.d/
sudo cp %s /etc/bash_completion.d/abra
echo "source /etc/bash_completion.d/abra" >> ~/.bashrc
# To test, run the following: "abra app <hit tab key>" - you should see command completion!
`, autocompletionFile))
case "zsh":
fmt.Println(fmt.Sprintf(`
# Run the following commands to install auto-completion
sudo mkdir /etc/zsh/completion.d/
sudo cp %s /etc/zsh/completion.d/abra
echo "PROG=abra\n_CLI_ZSH_AUTOCOMPLETE_HACK=1\nsource /etc/zsh/completion.d/abra" >> ~/.zshrc
# To test, run the following: "abra app <hit tab key>" - you should see command completion!
`, autocompletionFile))
case "fish":
fmt.Println(fmt.Sprintf(`
# Run the following commands to install auto-completion
sudo mkdir -p /etc/fish/completions
sudo cp %s /etc/fish/completions/abra
echo "source /etc/fish/completions/abra" >> ~/.config/fish/config.fish
# To test, run the following: "abra app <hit tab key>" - you should see command completion!
`, autocompletionFile))
}
return nil
},
}
// UpgradeCommand upgrades abra in-place.
var UpgradeCommand = cli.Command{
Name: "upgrade",
Aliases: []string{"u"},
Usage: "Upgrade Abra itself",
Description: `
Upgrade Abra in-place with the latest stable or release candidate.
Pass "-r/--rc" to install the latest release candidate. Please bear in mind
that it may contain catastrophic bugs. Thank you very much for the testing
efforts!
`,
Flags: []cli.Flag{internal.RCFlag},
Action: func(c *cli.Context) error {
mainURL := "https://install.abra.coopcloud.tech"
cmd := exec.Command("bash", "-c", fmt.Sprintf("wget -q -O- %s | bash", mainURL))
if internal.RC {
releaseCandidateURL := "https://git.coopcloud.tech/coop-cloud/abra/raw/branch/main/scripts/installer/installer"
cmd = exec.Command("bash", "-c", fmt.Sprintf("wget -q -O- %s | bash -s -- --rc", releaseCandidateURL))
}
logrus.Debugf("attempting to run %s", cmd)
if err := internal.RunCmd(cmd); err != nil {
logrus.Fatal(err)
}
return nil
},
} }
func newAbraApp(version, commit string) *cli.App { func newAbraApp(version, commit string) *cli.App {
app := &cli.App{ app := &cli.App{
Name: "abra", Name: "abra",
Usage: `The Co-op Cloud command-line utility belt 🎩🐇 Usage: `The Co-op Cloud command-line utility belt 🎩🐇
____ ____ _ _ ____ ____ _ _
/ ___|___ ___ _ __ / ___| | ___ _ _ __| | / ___|___ ___ _ __ / ___| | ___ _ _ __| |
| | / _ \ _____ / _ \| '_ \ | | | |/ _ \| | | |/ _' | | | / _ \ _____ / _ \| '_ \ | | | |/ _ \| | | |/ _' |
@ -153,50 +55,60 @@ func newAbraApp(version, commit string) *cli.App {
|_| |_|
`, `,
Version: fmt.Sprintf("%s-%s", version, commit[:7]), Version: fmt.Sprintf("%s-%s", version, commit[:7]),
Commands: []cli.Command{ Commands: []*cli.Command{
app.AppCommand, app.AppCommand,
server.ServerCommand, server.ServerCommand,
recipe.RecipeCommand, recipe.RecipeCommand,
catalogue.CatalogueCommand, catalogue.CatalogueCommand,
record.RecordCommand,
UpgradeCommand, UpgradeCommand,
AutoCompleteCommand, AutoCompleteCommand,
}, },
BashComplete: autocomplete.SubcommandComplete, Flags: []cli.Flag{
VerboseFlag,
DebugFlag,
internal.NoInputFlag,
},
Authors: []*cli.Author{
{Name: "3wordchant"},
{Name: "decentral1se"},
{Name: "knoflook"},
{Name: "roxxers"},
},
} }
app.EnableBashCompletion = true app.EnableBashCompletion = true
app.Before = func(c *cli.Context) error { app.Before = func(c *cli.Context) error {
if Debug {
logrus.SetLevel(logrus.DebugLevel)
logrus.SetFormatter(&logrus.TextFormatter{})
logrus.SetOutput(os.Stderr)
logrus.AddHook(logrusStack.StandardHook())
}
paths := []string{ paths := []string{
config.ABRA_DIR, config.ABRA_DIR,
config.SERVERS_DIR, path.Join(config.ABRA_DIR, "servers"),
config.RECIPES_DIR, path.Join(config.ABRA_DIR, "apps"),
config.VENDOR_DIR, path.Join(config.ABRA_DIR, "vendor"),
config.BACKUP_DIR,
} }
for _, path := range paths { for _, path := range paths {
if err := os.Mkdir(path, 0764); err != nil { if err := os.Mkdir(path, 0755); err != nil {
if !os.IsExist(err) { if !os.IsExist(err) {
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Debugf("'%s' already created, moving on...", path)
continue continue
} }
logrus.Debugf("'%s' is missing, creating...", path)
} }
if _, err := os.Stat(config.CATALOGUE_DIR); os.IsNotExist(err) { logrus.Debugf("abra version '%s', commit '%s'", version, commit)
url := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, config.CATALOGUE_JSON_REPO_NAME)
logrus.Warnf("local recipe catalogue is missing, retrieving now")
if err := git.Clone(config.CATALOGUE_DIR, url); err != nil {
logrus.Fatal(err)
}
}
logrus.Debugf("abra version %s, commit %s", version, commit)
return nil return nil
} }
return app return app
} }

View File

@ -0,0 +1,56 @@
package formatter
import (
"fmt"
"os"
"strings"
"time"
"github.com/docker/cli/cli/command/formatter"
"github.com/docker/go-units"
"github.com/olekukonko/tablewriter"
"github.com/schollz/progressbar/v3"
)
func ShortenID(str string) string {
return str[:12]
}
func Truncate(str string) string {
return fmt.Sprintf(`"%s"`, formatter.Ellipsis(str, 19))
}
func SmallSHA(hash string) string {
return hash[:8]
}
// RemoveSha remove image sha from a string that are added in some docker outputs
func RemoveSha(str string) string {
return strings.Split(str, "@")[0]
}
// HumanDuration from docker/cli RunningFor() to be accessible outside of the class
func HumanDuration(timestamp int64) string {
date := time.Unix(timestamp, 0)
now := time.Now().UTC()
return units.HumanDuration(now.Sub(date)) + " ago"
}
// CreateTable prepares a table layout for output.
func CreateTable(columns []string) *tablewriter.Table {
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader(columns)
return table
}
// CreateProgressbar generates a progress bar
func CreateProgressbar(length int, title string) *progressbar.ProgressBar {
return progressbar.NewOptions(
length,
progressbar.OptionClearOnFinish(),
progressbar.OptionSetPredictTime(false),
progressbar.OptionShowCount(),
progressbar.OptionFullWidth(),
progressbar.OptionSetDescription(title),
)
}

View File

@ -1,35 +0,0 @@
package internal
import (
"strings"
)
// SafeSplit splits up a string into a list of commands safely.
func SafeSplit(s string) []string {
split := strings.Split(s, " ")
var result []string
var inquote string
var block string
for _, i := range split {
if inquote == "" {
if strings.HasPrefix(i, "'") || strings.HasPrefix(i, "\"") {
inquote = string(i[0])
block = strings.TrimPrefix(i, inquote) + " "
} else {
result = append(result, i)
}
} else {
if !strings.HasSuffix(i, inquote) {
block += i + " "
} else {
block += strings.TrimSuffix(i, inquote)
inquote = ""
result = append(result, block)
block = ""
}
}
}
return result
}

View File

@ -1,251 +0,0 @@
package internal
import (
"os"
logrusStack "github.com/Gurpartap/logrus-stack"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
)
// Secrets stores the variable from SecretsFlag
var Secrets bool
// SecretsFlag turns on/off automatically generating secrets
var SecretsFlag = &cli.BoolFlag{
Name: "secrets, S",
Usage: "Automatically generate secrets",
Destination: &Secrets,
}
// Pass stores the variable from PassFlag
var Pass bool
// PassFlag turns on/off storing generated secrets in pass
var PassFlag = &cli.BoolFlag{
Name: "pass, p",
Usage: "Store the generated secrets in a local pass store",
Destination: &Pass,
}
// PassRemove stores the variable for PassRemoveFlag
var PassRemove bool
// PassRemoveFlag turns on/off removing generated secrets from pass
var PassRemoveFlag = &cli.BoolFlag{
Name: "pass, p",
Usage: "Remove generated secrets from a local pass store",
Destination: &PassRemove,
}
// Force force functionality without asking.
var Force bool
// ForceFlag turns on/off force functionality.
var ForceFlag = &cli.BoolFlag{
Name: "force, f",
Usage: "Perform action without further prompt. Use with care!",
Destination: &Force,
}
// Chaos engages chaos mode.
var Chaos bool
// ChaosFlag turns on/off chaos functionality.
var ChaosFlag = &cli.BoolFlag{
Name: "chaos, C",
Usage: "Deploy uncommitted recipes changes. Use with care!",
Destination: &Chaos,
}
// Disable tty to run commands from script
var Tty bool
// TtyFlag turns on/off tty mode.
var TtyFlag = &cli.BoolFlag{
Name: "tty, T",
Usage: "Disables TTY mode to run this command from a script.",
Destination: &Tty,
}
var NoInput bool
var NoInputFlag = &cli.BoolFlag{
Name: "no-input, n",
Usage: "Toggle non-interactive mode",
Destination: &NoInput,
}
// Debug stores the variable from DebugFlag.
var Debug bool
// DebugFlag turns on/off verbose logging down to the DEBUG level.
var DebugFlag = &cli.BoolFlag{
Name: "debug, d",
Destination: &Debug,
Usage: "Show DEBUG messages",
}
// Offline stores the variable from OfflineFlag.
var Offline bool
// DebugFlag turns on/off offline mode.
var OfflineFlag = &cli.BoolFlag{
Name: "offline, o",
Destination: &Offline,
Usage: "Prefer offline & filesystem access when possible",
}
// MachineReadable stores the variable from MachineReadableFlag
var MachineReadable bool
// MachineReadableFlag turns on/off machine readable output where supported
var MachineReadableFlag = &cli.BoolFlag{
Name: "machine, m",
Destination: &MachineReadable,
Usage: "Output in a machine-readable format (where supported)",
}
// RC signifies the latest release candidate
var RC bool
// RCFlag chooses the latest release candidate for install
var RCFlag = &cli.BoolFlag{
Name: "rc, r",
Destination: &RC,
Usage: "Install the latest release candidate",
}
var Major bool
var MajorFlag = &cli.BoolFlag{
Name: "major, x",
Usage: "Increase the major part of the version",
Destination: &Major,
}
var Minor bool
var MinorFlag = &cli.BoolFlag{
Name: "minor, y",
Usage: "Increase the minor part of the version",
Destination: &Minor,
}
var Patch bool
var PatchFlag = &cli.BoolFlag{
Name: "patch, z",
Usage: "Increase the patch part of the version",
Destination: &Patch,
}
var Dry bool
var DryFlag = &cli.BoolFlag{
Name: "dry-run, r",
Usage: "Only reports changes that would be made",
Destination: &Dry,
}
var Publish bool
var PublishFlag = &cli.BoolFlag{
Name: "publish, p",
Usage: "Publish changes to git.coopcloud.tech",
Destination: &Publish,
}
var Domain string
var DomainFlag = &cli.StringFlag{
Name: "domain, D",
Value: "",
Usage: "Choose a domain name",
Destination: &Domain,
}
var NewAppServer string
var NewAppServerFlag = &cli.StringFlag{
Name: "server, s",
Value: "",
Usage: "Show apps of a specific server",
Destination: &NewAppServer,
}
var NoDomainChecks bool
var NoDomainChecksFlag = &cli.BoolFlag{
Name: "no-domain-checks, D",
Usage: "Disable app domain sanity checks",
Destination: &NoDomainChecks,
}
var StdErrOnly bool
var StdErrOnlyFlag = &cli.BoolFlag{
Name: "stderr, s",
Usage: "Only tail stderr",
Destination: &StdErrOnly,
}
var SinceLogs string
var SinceLogsFlag = &cli.StringFlag{
Name: "since, S",
Value: "",
Usage: "tail logs since YYYY-MM-DDTHH:MM:SSZ",
Destination: &SinceLogs,
}
var DontWaitConverge bool
var DontWaitConvergeFlag = &cli.BoolFlag{
Name: "no-converge-checks, c",
Usage: "Don't wait for converge logic checks",
Destination: &DontWaitConverge,
}
var Watch bool
var WatchFlag = &cli.BoolFlag{
Name: "watch, w",
Usage: "Watch status by polling repeatedly",
Destination: &Watch,
}
var OnlyErrors bool
var OnlyErrorFlag = &cli.BoolFlag{
Name: "errors, e",
Usage: "Only show errors",
Destination: &OnlyErrors,
}
var SkipUpdates bool
var SkipUpdatesFlag = &cli.BoolFlag{
Name: "skip-updates, s",
Usage: "Skip updating recipe repositories",
Destination: &SkipUpdates,
}
var AllTags bool
var AllTagsFlag = &cli.BoolFlag{
Name: "all-tags, a",
Usage: "List all tags, not just upgrades",
Destination: &AllTags,
}
var LocalCmd bool
var LocalCmdFlag = &cli.BoolFlag{
Name: "local, l",
Usage: "Run command locally",
Destination: &LocalCmd,
}
var RemoteUser string
var RemoteUserFlag = &cli.StringFlag{
Name: "user, u",
Value: "",
Usage: "User to run command within a service context",
Destination: &RemoteUser,
}
// SubCommandBefore wires up pre-action machinery (e.g. --debug handling).
func SubCommandBefore(c *cli.Context) error {
if Debug {
logrus.SetLevel(logrus.DebugLevel)
logrus.SetFormatter(&logrus.TextFormatter{})
logrus.SetOutput(os.Stderr)
logrus.AddHook(logrusStack.StandardHook())
}
return nil
}

View File

@ -2,109 +2,10 @@ package internal
import ( import (
"bufio" "bufio"
"context"
"fmt" "fmt"
"io/ioutil"
"os/exec" "os/exec"
"strings"
"coopcloud.tech/abra/pkg/config"
containerPkg "coopcloud.tech/abra/pkg/container"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/upstream/container"
"github.com/docker/cli/cli/command"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
dockerClient "github.com/docker/docker/client"
"github.com/docker/docker/pkg/archive"
"github.com/sirupsen/logrus"
) )
// RunCmdRemote executes an abra.sh command in the target service
func RunCmdRemote(cl *dockerClient.Client, app config.App, abraSh, serviceName, cmdName, cmdArgs string) error {
filters := filters.NewArgs()
filters.Add("name", fmt.Sprintf("^%s_%s", app.StackName(), serviceName))
targetContainer, err := containerPkg.GetContainer(context.Background(), cl, filters, false)
if err != nil {
return err
}
logrus.Debugf("retrieved %s as target container on %s", formatter.ShortenID(targetContainer.ID), app.Server)
toTarOpts := &archive.TarOptions{NoOverwriteDirNonDir: true, Compression: archive.Gzip}
content, err := archive.TarWithOptions(abraSh, toTarOpts)
if err != nil {
return err
}
copyOpts := types.CopyToContainerOptions{AllowOverwriteDirWithFile: false, CopyUIDGID: false}
if err := cl.CopyToContainer(context.Background(), targetContainer.ID, "/tmp", content, copyOpts); err != nil {
return err
}
// FIXME: avoid instantiating a new CLI
dcli, err := command.NewDockerCli()
if err != nil {
return err
}
shell := "/bin/bash"
findShell := []string{"test", "-e", shell}
execCreateOpts := types.ExecConfig{
AttachStderr: true,
AttachStdin: true,
AttachStdout: true,
Cmd: findShell,
Detach: false,
Tty: false,
}
if err := container.RunExec(dcli, cl, targetContainer.ID, &execCreateOpts); err != nil {
logrus.Infof("%s does not exist for %s, use /bin/sh as fallback", shell, app.Name)
shell = "/bin/sh"
}
var cmd []string
if cmdArgs != "" {
cmd = []string{shell, "-c", fmt.Sprintf("TARGET=%s; APP_NAME=%s; STACK_NAME=%s; . /tmp/abra.sh; %s %s", serviceName, app.Name, app.StackName(), cmdName, cmdArgs)}
} else {
cmd = []string{shell, "-c", fmt.Sprintf("TARGET=%s; APP_NAME=%s; STACK_NAME=%s; . /tmp/abra.sh; %s", serviceName, app.Name, app.StackName(), cmdName)}
}
logrus.Debugf("running command: %s", strings.Join(cmd, " "))
if RemoteUser != "" {
logrus.Debugf("running command with user %s", RemoteUser)
execCreateOpts.User = RemoteUser
}
execCreateOpts.Cmd = cmd
execCreateOpts.Tty = true
if Tty {
execCreateOpts.Tty = false
}
if err := container.RunExec(dcli, cl, targetContainer.ID, &execCreateOpts); err != nil {
return err
}
return nil
}
func EnsureCommand(abraSh, recipeName, execCmd string) error {
bytes, err := ioutil.ReadFile(abraSh)
if err != nil {
return err
}
if !strings.Contains(string(bytes), execCmd) {
return fmt.Errorf("%s doesn't have a %s function", recipeName, execCmd)
}
return nil
}
// RunCmd runs a shell command and streams stdout/stderr in real-time. // RunCmd runs a shell command and streams stdout/stderr in real-time.
func RunCmd(cmd *exec.Cmd) error { func RunCmd(cmd *exec.Cmd) error {
r, err := cmd.StdoutPipe() r, err := cmd.StdoutPipe()

261
cli/internal/common.go Normal file
View File

@ -0,0 +1,261 @@
package internal
import (
"github.com/urfave/cli/v2"
)
// Secrets stores the variable from SecretsFlag
var Secrets bool
// SecretsFlag turns on/off automatically generating secrets
var SecretsFlag = &cli.BoolFlag{
Name: "secrets",
Aliases: []string{"S"},
Value: false,
Usage: "Automatically generate secrets",
Destination: &Secrets,
}
// Pass stores the variable from PassFlag
var Pass bool
// PassFlag turns on/off storing generated secrets in pass
var PassFlag = &cli.BoolFlag{
Name: "pass",
Aliases: []string{"P"},
Value: false,
Usage: "Store the generated secrets in a local pass store",
Destination: &Pass,
}
// Context is temp
var Context string
// ContextFlag is temp
var ContextFlag = &cli.StringFlag{
Name: "context",
Value: "",
Aliases: []string{"c"},
Destination: &Context,
}
// Force force functionality without asking.
var Force bool
// ForceFlag turns on/off force functionality.
var ForceFlag = &cli.BoolFlag{
Name: "force",
Value: false,
Aliases: []string{"f"},
Destination: &Force,
}
// Chaos engages chaos mode.
var Chaos bool
// ChaosFlag turns on/off chaos functionality.
var ChaosFlag = &cli.BoolFlag{
Name: "chaos",
Value: false,
Aliases: []string{"ch"},
Usage: "Deploy uncommitted recipes changes. Use with care!",
Destination: &Chaos,
}
// DNSProvider specifies a DNS provider.
var DNSProvider string
// DNSProviderFlag selects a DNS provider.
var DNSProviderFlag = &cli.StringFlag{
Name: "provider",
Value: "",
Aliases: []string{"p"},
Usage: "DNS provider",
Destination: &DNSProvider,
}
var NoInput bool
var NoInputFlag = &cli.BoolFlag{
Name: "no-input",
Value: false,
Aliases: []string{"n"},
Usage: "Toggle non-interactive mode",
Destination: &NoInput,
}
var DNSType string
var DNSTypeFlag = &cli.StringFlag{
Name: "type",
Value: "",
Aliases: []string{"t"},
Usage: "Domain name record type (e.g. A)",
Destination: &DNSType,
}
var DNSName string
var DNSNameFlag = &cli.StringFlag{
Name: "name",
Value: "",
Aliases: []string{"n"},
Usage: "Domain name record name (e.g. mysubdomain)",
Destination: &DNSName,
}
var DNSValue string
var DNSValueFlag = &cli.StringFlag{
Name: "value",
Value: "",
Aliases: []string{"v"},
Usage: "Domain name record value (e.g. 192.168.1.1)",
Destination: &DNSValue,
}
var DNSTTL int
var DNSTTLFlag = &cli.IntFlag{
Name: "ttl",
Value: 86400,
Aliases: []string{"T"},
Usage: "Domain name TTL value)",
Destination: &DNSTTL,
}
var DNSPriority int
var DNSPriorityFlag = &cli.IntFlag{
Name: "priority",
Value: 10,
Aliases: []string{"P"},
Usage: "Domain name priority value",
Destination: &DNSPriority,
}
var ServerProvider string
var ServerProviderFlag = &cli.StringFlag{
Name: "provider",
Aliases: []string{"p"},
Usage: "3rd party server provider",
Destination: &ServerProvider,
}
var CapsulInstanceURL string
var CapsulInstanceURLFlag = &cli.StringFlag{
Name: "capsul-url",
Value: "yolo.servers.coop",
Aliases: []string{"cu"},
Usage: "capsul instance URL",
Destination: &CapsulInstanceURL,
}
var CapsulName string
var CapsulNameFlag = &cli.StringFlag{
Name: "capsul-name",
Value: "",
Aliases: []string{"cn"},
Usage: "capsul name",
Destination: &CapsulName,
}
var CapsulType string
var CapsulTypeFlag = &cli.StringFlag{
Name: "capsul-type",
Value: "f1-xs",
Aliases: []string{"ct"},
Usage: "capsul type",
Destination: &CapsulType,
}
var CapsulImage string
var CapsulImageFlag = &cli.StringFlag{
Name: "capsul-image",
Value: "debian10",
Aliases: []string{"ci"},
Usage: "capsul image",
Destination: &CapsulImage,
}
var CapsulSSHKeys cli.StringSlice
var CapsulSSHKeysFlag = &cli.StringSliceFlag{
Name: "capsul-ssh-keys",
Aliases: []string{"cs"},
Usage: "capsul SSH key",
Destination: &CapsulSSHKeys,
}
var CapsulAPIToken string
var CapsulAPITokenFlag = &cli.StringFlag{
Name: "capsul-token",
Aliases: []string{"ca"},
Usage: "capsul API token",
EnvVars: []string{"CAPSUL_TOKEN"},
Destination: &CapsulAPIToken,
}
var HetznerCloudName string
var HetznerCloudNameFlag = &cli.StringFlag{
Name: "hetzner-name",
Value: "",
Aliases: []string{"hn"},
Usage: "hetzner cloud name",
Destination: &HetznerCloudName,
}
var HetznerCloudType string
var HetznerCloudTypeFlag = &cli.StringFlag{
Name: "hetzner-type",
Aliases: []string{"ht"},
Usage: "hetzner cloud type",
Destination: &HetznerCloudType,
Value: "cx11",
}
var HetznerCloudImage string
var HetznerCloudImageFlag = &cli.StringFlag{
Name: "hetzner-image",
Aliases: []string{"hi"},
Usage: "hetzner cloud image",
Value: "debian-10",
Destination: &HetznerCloudImage,
}
var HetznerCloudSSHKeys cli.StringSlice
var HetznerCloudSSHKeysFlag = &cli.StringSliceFlag{
Name: "hetzner-ssh-keys",
Aliases: []string{"hs"},
Usage: "hetzner cloud SSH keys (e.g. me@foo.com)",
Destination: &HetznerCloudSSHKeys,
}
var HetznerCloudLocation string
var HetznerCloudLocationFlag = &cli.StringFlag{
Name: "hetzner-location",
Aliases: []string{"hl"},
Usage: "hetzner cloud server location",
Value: "hel1",
Destination: &HetznerCloudLocation,
}
var HetznerCloudAPIToken string
var HetznerCloudAPITokenFlag = &cli.StringFlag{
Name: "hetzner-token",
Aliases: []string{"ha"},
Usage: "hetzner cloud API token",
EnvVars: []string{"HCLOUD_TOKEN"},
Destination: &HetznerCloudAPIToken,
}

View File

@ -2,142 +2,122 @@ package internal
import ( import (
"fmt" "fmt"
"io/ioutil"
"os"
"path"
"strings" "strings"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/upstream/stack"
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
dockerClient "github.com/docker/docker/client"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
) )
// NewVersionOverview shows an upgrade or downgrade overview // DeployAction is the main command-line action for this package
func NewVersionOverview(app config.App, currentVersion, newVersion, releaseNotes string) error { func DeployAction(c *cli.Context) error {
tableCol := []string{"server", "recipe", "config", "domain", "current version", "to be deployed"} app := ValidateApp(c)
table := formatter.CreateTable(tableCol) stackName := app.StackName()
deployConfig := "compose.yml" cl, err := client.New(app.Server)
if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok { if err != nil {
deployConfig = strings.Join(strings.Split(composeFiles, ":"), "\n") logrus.Fatal(err)
} }
server := app.Server logrus.Debugf("checking whether '%s' is already deployed", stackName)
if app.Server == "default" {
server = "local" isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, stackName)
if err != nil {
logrus.Fatal(err)
} }
table.Append([]string{server, app.Recipe, deployConfig, app.Domain, currentVersion, newVersion}) if isDeployed {
table.Render() if Force {
logrus.Warnf("'%s' already deployed but continuing (--force)", stackName)
if releaseNotes != "" && newVersion != "" { } else if Chaos {
fmt.Println() logrus.Warnf("'%s' already deployed but continuing (--chaos)", stackName)
fmt.Print(releaseNotes)
} else { } else {
logrus.Warnf("no release notes available for %s", newVersion) logrus.Fatalf("'%s' is already deployed", stackName)
}
} }
if NoInput { version := deployedVersion
return nil if version == "" && !Chaos {
} versions, err := catalogue.GetRecipeCatalogueVersions(app.Type)
response := false
prompt := &survey.Confirm{
Message: "continue with deployment?",
}
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
if !response {
logrus.Fatal("exiting as requested")
}
return nil
}
// GetReleaseNotes prints release notes for a recipe version
func GetReleaseNotes(recipeName, version string) (string, error) {
if version == "" {
return "", nil
}
fpath := path.Join(config.RECIPES_DIR, recipeName, "release", version)
if _, err := os.Stat(fpath); !os.IsNotExist(err) {
releaseNotes, err := ioutil.ReadFile(fpath)
if err != nil { if err != nil {
return "", err logrus.Fatal(err)
}
if len(versions) > 0 {
version = versions[len(versions)-1]
logrus.Debugf("choosing '%s' as version to deploy", version)
if err := recipe.EnsureVersion(app.Type, version); err != nil {
logrus.Fatal(err)
}
} else {
version = "latest commit"
logrus.Warn("no versions detected, using latest commit")
if err := recipe.EnsureLatest(app.Type); err != nil {
logrus.Fatal(err)
}
} }
withTitle := fmt.Sprintf("%s release notes:\n%s", version, string(releaseNotes))
return withTitle, nil
} }
return "", nil if version == "" && !Chaos {
logrus.Debugf("choosing '%s' as version to deploy", version)
if err := recipe.EnsureVersion(app.Type, version); err != nil {
logrus.Fatal(err)
}
} }
// PostCmds parses a string of commands and executes them inside of the respective services if Chaos {
// the commands string must have the following format: logrus.Warnf("chaos mode engaged")
// "<service> <command> <arguments>|<service> <command> <arguments>|... " var err error
func PostCmds(cl *dockerClient.Client, app config.App, commands string) error { version, err = recipe.ChaosVersion(app.Type)
abraSh := path.Join(config.RECIPES_DIR, app.Recipe, "abra.sh")
if _, err := os.Stat(abraSh); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf(fmt.Sprintf("%s does not exist for %s?", abraSh, app.Name))
}
return err
}
for _, command := range strings.Split(commands, "|") {
commandParts := strings.Split(command, " ")
if len(commandParts) < 2 {
return fmt.Errorf(fmt.Sprintf("not enough arguments: %s", command))
}
targetServiceName := commandParts[0]
cmdName := commandParts[1]
parsedCmdArgs := ""
if len(commandParts) > 2 {
parsedCmdArgs = fmt.Sprintf("%s ", strings.Join(commandParts[2:], " "))
}
logrus.Infof("running post-command '%s %s' in container %s", cmdName, parsedCmdArgs, targetServiceName)
if err := EnsureCommand(abraSh, app.Recipe, cmdName); err != nil {
return err
}
serviceNames, err := config.GetAppServiceNames(app.Name)
if err != nil { if err != nil {
return err logrus.Fatal(err)
}
matchingServiceName := false
for _, serviceName := range serviceNames {
if serviceName == targetServiceName {
matchingServiceName = true
} }
} }
if !matchingServiceName { abraShPath := fmt.Sprintf("%s/%s/%s", config.APPS_DIR, app.Type, "abra.sh")
return fmt.Errorf(fmt.Sprintf("no service %s for %s?", targetServiceName, app.Name)) abraShEnv, err := config.ReadAbraShEnvVars(abraShPath)
if err != nil {
logrus.Fatal(err)
}
for k, v := range abraShEnv {
app.Env[k] = v
} }
logrus.Debugf("running command %s %s within the context of %s_%s", cmdName, parsedCmdArgs, app.StackName(), targetServiceName) composeFiles, err := config.GetAppComposeFiles(app.Type, app.Env)
if err != nil {
logrus.Fatal(err)
}
deployOpts := stack.Deploy{
Composefiles: composeFiles,
Namespace: stackName,
Prune: false,
ResolveImage: stack.ResolveImageAlways,
}
compose, err := config.GetAppComposeConfig(app.Name, deployOpts, app.Env)
if err != nil {
logrus.Fatal(err)
}
Tty = true if err := DeployOverview(app, version, "continue with deployment?"); err != nil {
if err := RunCmdRemote(cl, app, abraSh, targetServiceName, cmdName, parsedCmdArgs); err != nil { logrus.Fatal(err)
return err
} }
if err := stack.RunDeploy(cl, deployOpts, compose); err != nil {
logrus.Fatal(err)
} }
return nil return nil
} }
// DeployOverview shows a deployment overview // DeployOverview shows a deployment overview
func DeployOverview(app config.App, version, message string) error { func DeployOverview(app config.App, version, message string) error {
tableCol := []string{"server", "recipe", "config", "domain", "version"} tableCol := []string{"server", "compose", "domain", "stack", "version"}
table := formatter.CreateTable(tableCol) table := abraFormatter.CreateTable(tableCol)
deployConfig := "compose.yml" deployConfig := "compose.yml"
if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok { if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok {
@ -149,7 +129,7 @@ func DeployOverview(app config.App, version, message string) error {
server = "local" server = "local"
} }
table.Append([]string{server, app.Recipe, deployConfig, app.Domain, version}) table.Append([]string{server, deployConfig, app.Domain, app.StackName(), version})
table.Render() table.Render()
if NoInput { if NoInput {
@ -171,3 +151,41 @@ func DeployOverview(app config.App, version, message string) error {
return nil return nil
} }
// NewVersionOverview shows an upgrade or downgrade overview
func NewVersionOverview(app config.App, currentVersion, newVersion string) error {
tableCol := []string{"server", "compose", "domain", "stack", "current version", "to be deployed"}
table := abraFormatter.CreateTable(tableCol)
deployConfig := "compose.yml"
if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok {
deployConfig = strings.Join(strings.Split(composeFiles, ":"), "\n")
}
server := app.Server
if app.Server == "default" {
server = "local"
}
table.Append([]string{server, deployConfig, app.Domain, app.StackName(), currentVersion, newVersion})
table.Render()
if NoInput {
return nil
}
response := false
prompt := &survey.Confirm{
Message: "continue with deployment?",
}
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
if !response {
logrus.Fatal("exiting as requested")
}
return nil
}

View File

@ -4,7 +4,7 @@ import (
"os" "os"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
// ShowSubcommandHelpAndError exits the program on error, logs the error to the // ShowSubcommandHelpAndError exits the program on error, logs the error to the

View File

@ -0,0 +1,38 @@
package internal
import (
"github.com/urfave/cli/v2"
)
// Testing functions that call os.Exit
// https://stackoverflow.com/questions/26225513/how-to-test-os-exit-scenarios-in-go
// https://talks.golang.org/2014/testing.slide#23
var testapp = &cli.App{
Name: "abra",
Usage: `The Co-op Cloud command-line utility belt 🎩🐇`,
}
// not testing output as that changes. just if it exits with code 1
// does not work because of some weird errors on cli's part. Its a hard lib to test effectively.
// func TestShowSubcommandHelpAndError(t *testing.T) {
// if os.Getenv("HelpAndError") == "1" {
// ShowSubcommandHelpAndError(cli.NewContext(testapp, nil, nil), errors.New("Test error"))
// return
// }
// cmd := exec.Command(os.Args[0], "-test.run=TestShowSubcommandHelpAndError")
// cmd.Env = append(os.Environ(), "HelpAndError=1")
// var out bytes.Buffer
// cmd.Stderr = &out
// err := cmd.Run()
// println(out.String())
// if !strings.Contains(out.String(), "Test error") {
// t.Fatalf("expected command to show the error causing the exit, did not get correct stdout output")
// }
// if e, ok := err.(*exec.ExitError); ok && !e.Success() {
// return
// }
// t.Fatalf("process ran with err %v, want exit status 1", err)
// }

View File

@ -1,10 +0,0 @@
package internal
// ReverseStringList reverses a list of a strings. Roll on Go generics.
func ReverseStringList(strings []string) []string {
for i, j := 0, len(strings)-1; i < j; i, j = i+1, j-1 {
strings[i], strings[j] = strings[j], strings[i]
}
return strings
}

203
cli/internal/new.go Normal file
View File

@ -0,0 +1,203 @@
package internal
import (
"fmt"
"path"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/secret"
"github.com/AlecAivazis/survey/v2"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
type AppSecrets map[string]string
var Domain string
var DomainFlag = &cli.StringFlag{
Name: "domain",
Aliases: []string{"d"},
Value: "",
Usage: "Choose a domain name",
Destination: &Domain,
}
var NewAppServer string
var NewAppServerFlag = &cli.StringFlag{
Name: "server",
Aliases: []string{"s"},
Value: "",
Usage: "Show apps of a specific server",
Destination: &NewAppServer,
}
var NewAppName string
var NewAppNameFlag = &cli.StringFlag{
Name: "app-name",
Aliases: []string{"a"},
Value: "",
Usage: "Choose an app name",
Destination: &NewAppName,
}
// RecipeName is used for configuring recipe name programmatically
var RecipeName string
// createSecrets creates all secrets for a new app.
func createSecrets(sanitisedAppName string) (AppSecrets, error) {
appEnvPath := path.Join(config.ABRA_DIR, "servers", NewAppServer, fmt.Sprintf("%s.env", sanitisedAppName))
appEnv, err := config.ReadEnv(appEnvPath)
if err != nil {
return nil, err
}
secretEnvVars := secret.ReadSecretEnvVars(appEnv)
secrets, err := secret.GenerateSecrets(secretEnvVars, sanitisedAppName, NewAppServer)
if err != nil {
return nil, err
}
if Pass {
for secretName := range secrets {
secretValue := secrets[secretName]
if err := secret.PassInsertSecret(secretValue, secretName, sanitisedAppName, NewAppServer); err != nil {
return nil, err
}
}
}
return secrets, nil
}
// ensureDomainFlag checks if the domain flag was used. if not, asks the user for it/
func ensureDomainFlag(recipe recipe.Recipe, server string) error {
if Domain == "" && !NoInput {
prompt := &survey.Input{
Message: "Specify app domain",
Default: fmt.Sprintf("%s.%s", recipe.Name, server),
}
if err := survey.AskOne(prompt, &Domain); err != nil {
return err
}
}
if Domain == "" {
return fmt.Errorf("no domain provided")
}
return nil
}
// ensureServerFlag checks if the server flag was used. if not, asks the user for it.
func ensureServerFlag() error {
servers, err := config.GetServers()
if err != nil {
return err
}
if NewAppServer == "" && !NoInput {
prompt := &survey.Select{
Message: "Select app server:",
Options: servers,
}
if err := survey.AskOne(prompt, &NewAppServer); err != nil {
return err
}
}
if NewAppServer == "" {
return fmt.Errorf("no server provided")
}
return nil
}
// ensureServerFlag checks if the AppName flag was used. if not, asks the user for it.
func ensureAppNameFlag() error {
if NewAppName == "" && !NoInput {
prompt := &survey.Input{
Message: "Specify app name:",
Default: config.SanitiseAppName(Domain),
}
if err := survey.AskOne(prompt, &NewAppName); err != nil {
return err
}
}
if NewAppName == "" {
return fmt.Errorf("no app name provided")
}
return nil
}
// NewAction is the new app creation logic
func NewAction(c *cli.Context) error {
recipe := ValidateRecipeWithPrompt(c)
if err := config.EnsureAbraDirExists(); err != nil {
logrus.Fatal(err)
}
if err := ensureServerFlag(); err != nil {
logrus.Fatal(err)
}
if err := ensureDomainFlag(recipe, NewAppServer); err != nil {
logrus.Fatal(err)
}
if err := ensureAppNameFlag(); err != nil {
logrus.Fatal(err)
}
sanitisedAppName := config.SanitiseAppName(NewAppName)
if len(sanitisedAppName) > 45 {
logrus.Fatalf("'%s' cannot be longer than 45 characters", sanitisedAppName)
}
logrus.Debugf("'%s' sanitised as '%s' for new app", NewAppName, sanitisedAppName)
if err := config.TemplateAppEnvSample(recipe.Name, NewAppName, NewAppServer, Domain, recipe.Name); err != nil {
logrus.Fatal(err)
}
if Secrets {
secrets, err := createSecrets(sanitisedAppName)
if err != nil {
logrus.Fatal(err)
}
secretCols := []string{"Name", "Value"}
secretTable := abraFormatter.CreateTable(secretCols)
for secret := range secrets {
secretTable.Append([]string{secret, secrets[secret]})
}
if len(secrets) > 0 {
defer secretTable.Render()
}
}
if NewAppServer == "default" {
NewAppServer = "local"
}
tableCol := []string{"Name", "Domain", "Type", "Server"}
table := abraFormatter.CreateTable(tableCol)
table.Append([]string{sanitisedAppName, Domain, recipe.Name, NewAppServer})
fmt.Println("")
fmt.Println(fmt.Sprintf("A new %s app has been created! Here is an overview:", recipe.Name))
fmt.Println("")
table.Render()
fmt.Println("")
fmt.Println("You can configure this app by running the following:")
fmt.Println(fmt.Sprintf("\n abra app config %s", sanitisedAppName))
fmt.Println("")
fmt.Println("You can deploy this app by running the following:")
fmt.Println(fmt.Sprintf("\n abra app deploy %s", sanitisedAppName))
fmt.Println("")
return nil
}

View File

@ -2,55 +2,104 @@ package internal
import ( import (
"fmt" "fmt"
"strings"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/recipe"
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
"github.com/docker/distribution/reference"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
) )
var Major bool
var MajorFlag = &cli.BoolFlag{
Name: "major",
Usage: "Increase the major part of the version",
Value: false,
Aliases: []string{"ma", "x"},
Destination: &Major,
}
var Minor bool
var MinorFlag = &cli.BoolFlag{
Name: "minor",
Usage: "Increase the minor part of the version",
Value: false,
Aliases: []string{"mi", "y"},
Destination: &Minor,
}
var Patch bool
var PatchFlag = &cli.BoolFlag{
Name: "patch",
Usage: "Increase the patch part of the version",
Value: false,
Aliases: []string{"p", "z"},
Destination: &Patch,
}
var Dry bool
var DryFlag = &cli.BoolFlag{
Name: "dry-run",
Usage: "No changes are made, only reports changes that would be made",
Value: false,
Aliases: []string{"d"},
Destination: &Dry,
}
var Push bool
var PushFlag = &cli.BoolFlag{
Name: "push",
Usage: "Git push changes",
Value: false,
Aliases: []string{"P"},
Destination: &Push,
}
var CommitMessage string
var CommitMessageFlag = &cli.StringFlag{
Name: "commit-message",
Usage: "Commit message (implies --commit)",
Aliases: []string{"cm"},
Destination: &CommitMessage,
}
var Commit bool
var CommitFlag = &cli.BoolFlag{
Name: "commit",
Usage: "Commits compose.**yml file changes to recipe repository",
Value: false,
Aliases: []string{"c"},
Destination: &Commit,
}
var TagMessage string
var TagMessageFlag = &cli.StringFlag{
Name: "tag-comment",
Usage: "Description for release tag",
Aliases: []string{"t", "tm"},
Destination: &TagMessage,
}
// PromptBumpType prompts for version bump type // PromptBumpType prompts for version bump type
func PromptBumpType(tagString, latestRelease string) error { func PromptBumpType(tagString string) error {
if (!Major && !Minor && !Patch) && tagString == "" { if (!Major && !Minor && !Patch) && tagString == "" {
fmt.Printf(` fmt.Printf(`
You need to make a decision about what kind of an update this new recipe semver cheat sheet (more via semver.org):
version is. If someone else performs this upgrade, do they have to do some major: new features/bug fixes, backwards incompatible
migration work or take care of some breaking changes? This can be signaled in minor: new features/bug fixes, backwards compatible
the version you specify on the recipe deploy label and is called a semantic patch: bug fixes, backwards compatible
version.
The latest published version is %s.
Here is a semver cheat sheet (more on https://semver.org):
major: new features/bug fixes, backwards incompatible (e.g 1.0.0 -> 2.0.0).
the upgrade won't work without some preparation work and others need
to take care when performing it. "it could go wrong".
minor: new features/bug fixes, backwards compatible (e.g. 0.1.0 -> 0.2.0).
the upgrade should Just Work and there are no breaking changes in
the app and the recipe config. "it should go fine".
patch: bug fixes, backwards compatible (e.g. 0.0.1 -> 0.0.2). this upgrade
should also Just Work and is mostly to do with minor bug fixes
and/or security patches. "nothing to worry about".
`, latestRelease)
`)
var chosenBumpType string var chosenBumpType string
prompt := &survey.Select{ prompt := &survey.Select{
Message: fmt.Sprintf("select recipe version increment type"), Message: fmt.Sprintf("select recipe version increment type"),
Options: []string{"major", "minor", "patch"}, Options: []string{"major", "minor", "patch"},
} }
if err := survey.AskOne(prompt, &chosenBumpType); err != nil { if err := survey.AskOne(prompt, &chosenBumpType); err != nil {
return err return err
} }
SetBumpType(chosenBumpType) SetBumpType(chosenBumpType)
} }
return nil return nil
} }
@ -84,27 +133,20 @@ func SetBumpType(bumpType string) {
} }
} }
// GetMainAppImage retrieves the main 'app' image name // GetMainApp retrieves the main 'app' image name
func GetMainAppImage(recipe recipe.Recipe) (string, error) { func GetMainApp(recipe recipe.Recipe) string {
var path string var app string
for _, service := range recipe.Config.Services { for _, service := range recipe.Config.Services {
if service.Name == "app" { name := service.Name
img, err := reference.ParseNormalizedNamed(service.Image) if name == "app" {
if err != nil { app = strings.Split(service.Image, ":")[0]
return "", err
}
path = reference.Path(img)
path = formatter.StripTagMeta(path)
return path, nil
} }
} }
if path == "" { if app == "" {
return path, fmt.Errorf("%s has no main 'app' service?", recipe.Name) logrus.Fatalf("%s has no main 'app' service?", recipe.Name)
} }
return path, nil return app
} }

106
cli/internal/record.go Normal file
View File

@ -0,0 +1,106 @@
package internal
import (
"errors"
"fmt"
"github.com/AlecAivazis/survey/v2"
"github.com/urfave/cli/v2"
)
// EnsureDNSProvider ensures a DNS provider is chosen.
func EnsureDNSProvider() error {
if DNSProvider == "" && !NoInput {
prompt := &survey.Select{
Message: "Select DNS provider",
Options: []string{"gandi"},
}
if err := survey.AskOne(prompt, &DNSProvider); err != nil {
return err
}
}
if DNSProvider == "" {
return fmt.Errorf("missing DNS provider?")
}
return nil
}
// EnsureDNSTypeFlag ensures a DNS type flag is present.
func EnsureDNSTypeFlag(c *cli.Context) error {
if DNSType == "" && !NoInput {
prompt := &survey.Input{
Message: "Specify DNS record type",
Default: "A",
}
if err := survey.AskOne(prompt, &DNSType); err != nil {
return err
}
}
if DNSType == "" {
ShowSubcommandHelpAndError(c, errors.New("no record type provided"))
}
return nil
}
// EnsureDNSNameFlag ensures a DNS name flag is present.
func EnsureDNSNameFlag(c *cli.Context) error {
if DNSName == "" && !NoInput {
prompt := &survey.Input{
Message: "Specify DNS record name",
Default: "mysubdomain",
}
if err := survey.AskOne(prompt, &DNSName); err != nil {
return err
}
}
if DNSName == "" {
ShowSubcommandHelpAndError(c, errors.New("no record name provided"))
}
return nil
}
// EnsureDNSValueFlag ensures a DNS value flag is present.
func EnsureDNSValueFlag(c *cli.Context) error {
if DNSValue == "" && !NoInput {
prompt := &survey.Input{
Message: "Specify DNS record value",
Default: "192.168.1.2",
}
if err := survey.AskOne(prompt, &DNSValue); err != nil {
return err
}
}
if DNSName == "" {
ShowSubcommandHelpAndError(c, errors.New("no record value provided"))
}
return nil
}
// EnsureZoneArgument ensures a zone argument is present.
func EnsureZoneArgument(c *cli.Context) (string, error) {
var zone string
if c.Args().First() == "" && !NoInput {
prompt := &survey.Input{
Message: "Specify a domain name zone",
Default: "example.com",
}
if err := survey.AskOne(prompt, &zone); err != nil {
return zone, err
}
}
if zone == "" {
ShowSubcommandHelpAndError(c, errors.New("no zone value provided"))
}
return zone, nil
}

208
cli/internal/server.go Normal file
View File

@ -0,0 +1,208 @@
package internal
import (
"fmt"
"os"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/urfave/cli/v2"
)
// EnsureServerProvider ensures a 3rd party server provider is chosen.
func EnsureServerProvider() error {
if ServerProvider == "" && !NoInput {
prompt := &survey.Select{
Message: "Select server provider",
Options: []string{"capsul", "hetzner-cloud"},
}
if err := survey.AskOne(prompt, &ServerProvider); err != nil {
return err
}
}
if ServerProvider == "" {
return fmt.Errorf("missing server provider?")
}
return nil
}
// EnsureNewCapsulVPSFlags ensure all flags are present.
func EnsureNewCapsulVPSFlags(c *cli.Context) error {
if CapsulName == "" && !NoInput {
prompt := &survey.Input{
Message: "specify capsul name",
}
if err := survey.AskOne(prompt, &CapsulName); err != nil {
return err
}
}
if !NoInput {
prompt := &survey.Input{
Message: "specify capsul instance URL",
Default: CapsulInstanceURL,
}
if err := survey.AskOne(prompt, &CapsulInstanceURL); err != nil {
return err
}
}
if !NoInput {
prompt := &survey.Input{
Message: "specify capsul type",
Default: CapsulType,
}
if err := survey.AskOne(prompt, &CapsulType); err != nil {
return err
}
}
if !NoInput {
prompt := &survey.Input{
Message: "specify capsul image",
Default: CapsulImage,
}
if err := survey.AskOne(prompt, &CapsulImage); err != nil {
return err
}
}
if len(CapsulSSHKeys.Value()) == 0 && !NoInput {
var sshKeys string
prompt := &survey.Input{
Message: "specify capsul SSH keys (e.g. me@foo.com)",
Default: "",
}
if err := survey.AskOne(prompt, &CapsulSSHKeys); err != nil {
return err
}
CapsulSSHKeys = *cli.NewStringSlice(strings.Split(sshKeys, ",")...)
}
if CapsulAPIToken == "" && !NoInput {
token, ok := os.LookupEnv("CAPSUL_TOKEN")
if !ok {
prompt := &survey.Input{
Message: "specify capsul API token",
}
if err := survey.AskOne(prompt, &CapsulAPIToken); err != nil {
return err
}
} else {
CapsulAPIToken = token
}
}
if CapsulName == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul name?"))
}
if CapsulInstanceURL == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul instance url?"))
}
if CapsulType == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul type?"))
}
if CapsulImage == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul image?"))
}
if len(CapsulSSHKeys.Value()) == 0 {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul ssh keys?"))
}
if CapsulAPIToken == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul API token?"))
}
return nil
}
// EnsureNewHetznerCloudVPSFlags ensure all flags are present.
func EnsureNewHetznerCloudVPSFlags(c *cli.Context) error {
if HetznerCloudName == "" && !NoInput {
prompt := &survey.Input{
Message: "specify hetzner cloud VPS name",
}
if err := survey.AskOne(prompt, &HetznerCloudName); err != nil {
return err
}
}
if !NoInput {
prompt := &survey.Input{
Message: "specify hetzner cloud VPS type",
Default: HetznerCloudType,
}
if err := survey.AskOne(prompt, &HetznerCloudType); err != nil {
return err
}
}
if !NoInput {
prompt := &survey.Input{
Message: "specify hetzner cloud VPS image",
Default: HetznerCloudImage,
}
if err := survey.AskOne(prompt, &HetznerCloudImage); err != nil {
return err
}
}
if len(HetznerCloudSSHKeys.Value()) == 0 && !NoInput {
var sshKeys string
prompt := &survey.Input{
Message: "specify hetzner cloud SSH keys (e.g. me@foo.com)",
Default: "",
}
if err := survey.AskOne(prompt, &sshKeys); err != nil {
return err
}
HetznerCloudSSHKeys = *cli.NewStringSlice(strings.Split(sshKeys, ",")...)
}
if !NoInput {
prompt := &survey.Input{
Message: "specify hetzner cloud VPS location",
Default: HetznerCloudLocation,
}
if err := survey.AskOne(prompt, &HetznerCloudLocation); err != nil {
return err
}
}
if HetznerCloudAPIToken == "" && !NoInput {
token, ok := os.LookupEnv("HCLOUD_TOKEN")
if !ok {
prompt := &survey.Input{
Message: "specify hetzner cloud API token",
}
if err := survey.AskOne(prompt, &HetznerCloudAPIToken); err != nil {
return err
}
} else {
HetznerCloudAPIToken = token
}
}
if HetznerCloudName == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud VPS name?"))
}
if HetznerCloudType == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud VPS type?"))
}
if HetznerCloudImage == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud image?"))
}
if len(HetznerCloudSSHKeys.Value()) == 0 {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud ssh keys?"))
}
if HetznerCloudLocation == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud VPS location?"))
}
if HetznerCloudAPIToken == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud API token?"))
}
return nil
}

View File

@ -5,45 +5,49 @@ import (
"strings" "strings"
"coopcloud.tech/abra/pkg/app" "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/recipe"
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
// AppName is used for configuring app name programmatically
var AppName string
// ValidateRecipe ensures the recipe arg is valid. // ValidateRecipe ensures the recipe arg is valid.
func ValidateRecipe(c *cli.Context) recipe.Recipe { func ValidateRecipe(c *cli.Context) recipe.Recipe {
recipeName := c.Args().First() recipeName := c.Args().First()
if recipeName == "" {
ShowSubcommandHelpAndError(c, errors.New("no recipe provided"))
}
recipe, err := recipe.Get(recipeName)
if err != nil {
logrus.Fatal(err)
}
logrus.Debugf("validated '%s' as recipe argument", recipeName)
return recipe
}
// ValidateRecipeWithPrompt ensures a recipe argument is present before
// validating, asking for input if required.
func ValidateRecipeWithPrompt(c *cli.Context) recipe.Recipe {
recipeName := c.Args().First()
if recipeName == "" && !NoInput { if recipeName == "" && !NoInput {
catl, err := catalogue.ReadRecipeCatalogue()
if err != nil {
logrus.Fatal(err)
}
var recipes []string var recipes []string
catl, err := recipe.ReadRecipeCatalogue(Offline)
if err != nil {
logrus.Fatal(err)
}
knownRecipes := make(map[string]bool)
for name := range catl { for name := range catl {
knownRecipes[name] = true recipes = append(recipes, name)
} }
localRecipes, err := recipe.GetRecipesLocal()
if err != nil {
logrus.Fatal(err)
}
for _, recipeLocal := range localRecipes {
if _, ok := knownRecipes[recipeLocal]; !ok {
knownRecipes[recipeLocal] = true
}
}
for recipeName := range knownRecipes {
recipes = append(recipes, recipeName)
}
prompt := &survey.Select{ prompt := &survey.Select{
Message: "Select recipe", Message: "Select recipe",
Options: recipes, Options: recipes,
@ -53,34 +57,34 @@ func ValidateRecipe(c *cli.Context) recipe.Recipe {
} }
} }
if recipeName == "" { if RecipeName != "" {
ShowSubcommandHelpAndError(c, errors.New("no recipe name provided")) recipeName = RecipeName
logrus.Debugf("programmatically setting recipe name to %s", recipeName)
} }
chosenRecipe, err := recipe.Get(recipeName, Offline) if recipeName == "" {
ShowSubcommandHelpAndError(c, errors.New("no recipe provided"))
}
recipe, err := recipe.Get(recipeName)
if err != nil { if err != nil {
if c.Command.Name == "generate" {
if strings.Contains(err.Error(), "missing a compose") {
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Warn(err)
} else {
if strings.Contains(err.Error(), "template_driver is not allowed") {
logrus.Warnf("ensure %s recipe compose.* files include \"version: '3.8'\"", recipeName)
}
logrus.Fatalf("unable to validate recipe: %s", err)
}
}
logrus.Debugf("validated %s as recipe argument", recipeName) logrus.Debugf("validated '%s' as recipe argument", recipeName)
return chosenRecipe return recipe
} }
// ValidateApp ensures the app name arg is valid. // ValidateApp ensures the app name arg is valid.
func ValidateApp(c *cli.Context) config.App { func ValidateApp(c *cli.Context) config.App {
appName := c.Args().First() appName := c.Args().First()
if AppName != "" {
appName = AppName
logrus.Debugf("programmatically setting app name to %s", appName)
}
if appName == "" { if appName == "" {
ShowSubcommandHelpAndError(c, errors.New("no app provided")) ShowSubcommandHelpAndError(c, errors.New("no app provided"))
} }
@ -90,13 +94,17 @@ func ValidateApp(c *cli.Context) config.App {
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Debugf("validated %s as app argument", appName) if err := recipe.EnsureExists(app.Type); err != nil {
logrus.Fatal(err)
}
logrus.Debugf("validated '%s' as app argument", appName)
return app return app
} }
// ValidateDomain ensures the domain name arg is valid. // ValidateDomain ensures the domain name arg is valid.
func ValidateDomain(c *cli.Context) string { func ValidateDomain(c *cli.Context) (string, error) {
domainName := c.Args().First() domainName := c.Args().First()
if domainName == "" && !NoInput { if domainName == "" && !NoInput {
@ -105,7 +113,7 @@ func ValidateDomain(c *cli.Context) string {
Default: "example.com", Default: "example.com",
} }
if err := survey.AskOne(prompt, &domainName); err != nil { if err := survey.AskOne(prompt, &domainName); err != nil {
logrus.Fatal(err) return domainName, err
} }
} }
@ -113,16 +121,16 @@ func ValidateDomain(c *cli.Context) string {
ShowSubcommandHelpAndError(c, errors.New("no domain provided")) ShowSubcommandHelpAndError(c, errors.New("no domain provided"))
} }
logrus.Debugf("validated %s as domain argument", domainName) logrus.Debugf("validated '%s' as domain argument", domainName)
return domainName return domainName, nil
} }
// ValidateSubCmdFlags ensures flag order conforms to correct order // ValidateSubCmdFlags ensures flag order conforms to correct order
func ValidateSubCmdFlags(c *cli.Context) bool { func ValidateSubCmdFlags(c *cli.Context) bool {
for argIdx, arg := range c.Args() { for argIdx, arg := range c.Args().Slice() {
if !strings.HasPrefix(arg, "--") { if !strings.HasPrefix(arg, "--") {
for _, flag := range c.Args()[argIdx:] { for _, flag := range c.Args().Slice()[argIdx:] {
if strings.HasPrefix(flag, "--") { if strings.HasPrefix(flag, "--") {
return false return false
} }
@ -133,12 +141,12 @@ func ValidateSubCmdFlags(c *cli.Context) bool {
} }
// ValidateServer ensures the server name arg is valid. // ValidateServer ensures the server name arg is valid.
func ValidateServer(c *cli.Context) string { func ValidateServer(c *cli.Context) (string, error) {
serverName := c.Args().First() serverName := c.Args().First()
serverNames, err := config.ReadServerNames() serverNames, err := config.ReadServerNames()
if err != nil { if err != nil {
logrus.Fatal(err) return serverName, err
} }
if serverName == "" && !NoInput { if serverName == "" && !NoInput {
@ -147,14 +155,7 @@ func ValidateServer(c *cli.Context) string {
Options: serverNames, Options: serverNames,
} }
if err := survey.AskOne(prompt, &serverName); err != nil { if err := survey.AskOne(prompt, &serverName); err != nil {
logrus.Fatal(err) return serverName, err
}
}
matched := false
for _, name := range serverNames {
if name == serverName {
matched = true
} }
} }
@ -162,11 +163,7 @@ func ValidateServer(c *cli.Context) string {
ShowSubcommandHelpAndError(c, errors.New("no server provided")) ShowSubcommandHelpAndError(c, errors.New("no server provided"))
} }
if !matched { logrus.Debugf("validated '%s' as server argument", serverName)
ShowSubcommandHelpAndError(c, errors.New("server doesn't exist?"))
}
logrus.Debugf("validated %s as server argument", serverName) return serverName, nil
return serverName
} }

View File

@ -1,44 +0,0 @@
package recipe
import (
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/recipe"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
)
var recipeFetchCommand = cli.Command{
Name: "fetch",
Usage: "Fetch recipe(s)",
Aliases: []string{"f"},
ArgsUsage: "[<recipe>]",
Description: "Retrieves all recipes if no <recipe> argument is passed",
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.RecipeNameComplete,
Action: func(c *cli.Context) error {
recipeName := c.Args().First()
if recipeName != "" {
internal.ValidateRecipe(c)
}
if err := recipe.EnsureExists(recipeName); err != nil {
logrus.Fatal(err)
}
if err := recipe.EnsureUpToDate(recipeName); err != nil {
logrus.Fatal(err)
}
if err := recipe.EnsureLatest(recipeName); err != nil {
logrus.Fatal(err)
}
return nil
},
}

View File

@ -2,121 +2,112 @@ package recipe
import ( import (
"fmt" "fmt"
"os"
"strconv"
"coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/lint" "coopcloud.tech/tagcmp"
recipePkg "coopcloud.tech/abra/pkg/recipe" "github.com/docker/distribution/reference"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var recipeLintCommand = cli.Command{ var recipeLintCommand = &cli.Command{
Name: "lint", Name: "lint",
Usage: "Lint a recipe", Usage: "Lint a recipe",
Aliases: []string{"l"}, Aliases: []string{"l"},
ArgsUsage: "<recipe>", ArgsUsage: "<recipe>",
Flags: []cli.Flag{
internal.DebugFlag,
internal.OnlyErrorFlag,
internal.OfflineFlag,
internal.NoInputFlag,
internal.ChaosFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.RecipeNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipe(c) recipe := internal.ValidateRecipe(c)
if err := recipePkg.EnsureExists(recipe.Name); err != nil { expectedVersion := false
if recipe.Config.Version == "3.8" {
expectedVersion = true
}
envSampleProvided := false
envSample := fmt.Sprintf("%s/%s/.env.sample", config.APPS_DIR, recipe.Name)
if _, err := os.Stat(envSample); !os.IsNotExist(err) {
envSampleProvided = true
} else if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if !internal.Chaos { serviceNamedApp := false
if err := recipePkg.EnsureIsClean(recipe.Name); err != nil { traefikEnabled := false
logrus.Fatal(err) healthChecksForAllServices := true
allImagesTagged := true
noUnstableTags := true
semverLikeTags := true
for _, service := range recipe.Config.Services {
if service.Name == "app" {
serviceNamedApp = true
} }
if !internal.Offline { for label := range service.Deploy.Labels {
if err := recipePkg.EnsureUpToDate(recipe.Name); err != nil { if label == "traefik.enable" {
logrus.Fatal(err) if service.Deploy.Labels[label] == "true" {
traefikEnabled = true
}
} }
} }
if err := recipePkg.EnsureLatest(recipe.Name); err != nil { img, err := reference.ParseNormalizedNamed(service.Image)
logrus.Fatal(err)
}
}
tableCol := []string{"ref", "rule", "severity", "satisfied", "skipped", "resolve"}
table := formatter.CreateTable(tableCol)
hasError := false
bar := formatter.CreateProgressbar(-1, "running recipe lint rules...")
for level := range lint.LintRules {
for _, rule := range lint.LintRules[level] {
if internal.OnlyErrors && rule.Level != "error" {
logrus.Debugf("skipping %s, does not have level \"error\"", rule.Ref)
continue
}
skipped := false
if rule.Skip(recipe) {
skipped = true
}
skippedOutput := "-"
if skipped {
skippedOutput = "yes"
}
satisfied := false
if !skipped {
ok, err := rule.Function(recipe)
if err != nil { if err != nil {
logrus.Warn(err) logrus.Fatal(err)
}
if reference.IsNameOnly(img) {
allImagesTagged = false
} }
if !ok && rule.Level == "error" { var tag string
hasError = true switch img.(type) {
case reference.NamedTagged:
tag = img.(reference.NamedTagged).Tag()
case reference.Named:
noUnstableTags = false
} }
if ok { if tag == "latest" {
satisfied = true noUnstableTags = false
}
if !tagcmp.IsParsable(tag) {
semverLikeTags = false
}
if service.HealthCheck == nil {
healthChecksForAllServices = false
} }
} }
satisfiedOutput := "yes" tableCol := []string{"rule", "satisfied"}
if !satisfied { table := formatter.CreateTable(tableCol)
satisfiedOutput = "NO" table.Append([]string{"compose files have the expected version", strconv.FormatBool(expectedVersion)})
if skipped { table.Append([]string{"environment configuration is provided", strconv.FormatBool(envSampleProvided)})
satisfiedOutput = "-" table.Append([]string{"recipe contains a service named 'app'", strconv.FormatBool(serviceNamedApp)})
} table.Append([]string{"traefik routing enabled on at least one service", strconv.FormatBool(traefikEnabled)})
} table.Append([]string{"all services have a healthcheck enabled", strconv.FormatBool(healthChecksForAllServices)})
table.Append([]string{"all images are using a tag", strconv.FormatBool(allImagesTagged)})
table.Append([]string{ table.Append([]string{"no usage of unstable 'latest' tags", strconv.FormatBool(noUnstableTags)})
rule.Ref, table.Append([]string{"all tags are using a semver-like format", strconv.FormatBool(semverLikeTags)})
rule.Description,
rule.Level,
satisfiedOutput,
skippedOutput,
rule.HowToResolve,
})
bar.Add(1)
}
}
if table.NumLines() > 0 {
fmt.Println()
table.Render() table.Render()
}
if hasError {
logrus.Warn("watch out, some critical errors are present in your recipe config")
}
return nil return nil
}, },
BashComplete: func(c *cli.Context) {
catl, err := catalogue.ReadRecipeCatalogue()
if err != nil {
logrus.Warn(err)
}
if c.NArg() > 0 {
return
}
for name := range catl {
fmt.Println(name)
}
},
} }

View File

@ -3,80 +3,36 @@ package recipe
import ( import (
"fmt" "fmt"
"sort" "sort"
"strconv"
"strings"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/recipe"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var pattern string var recipeListCommand = &cli.Command{
var patternFlag = &cli.StringFlag{
Name: "pattern, p",
Value: "",
Usage: "Simple string to filter recipes",
Destination: &pattern,
}
var recipeListCommand = cli.Command{
Name: "list", Name: "list",
Usage: "List available recipes", Usage: "List available recipes",
Aliases: []string{"ls"}, Aliases: []string{"ls"},
Flags: []cli.Flag{
internal.DebugFlag,
internal.MachineReadableFlag,
patternFlag,
internal.OfflineFlag,
},
Before: internal.SubCommandBefore,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
catl, err := recipe.ReadRecipeCatalogue(internal.Offline) catl, err := catalogue.ReadRecipeCatalogue()
if err != nil { if err != nil {
logrus.Fatal(err.Error()) logrus.Fatal(err.Error())
} }
recipes := catl.Flatten() recipes := catl.Flatten()
sort.Sort(recipe.ByRecipeName(recipes)) sort.Sort(catalogue.ByRecipeName(recipes))
tableCol := []string{"name", "category", "status", "healthcheck", "backups", "email", "tests", "SSO"} tableCol := []string{"name", "category", "status"}
table := formatter.CreateTable(tableCol) table := formatter.CreateTable(tableCol)
len := 0
for _, recipe := range recipes { for _, recipe := range recipes {
tableRow := []string{ status := fmt.Sprintf("%v", recipe.Features.Status)
recipe.Name, tableRow := []string{recipe.Name, recipe.Category, status}
recipe.Category, table.Append(tableRow)
strconv.Itoa(recipe.Features.Status),
recipe.Features.Healthcheck,
recipe.Features.Backups,
recipe.Features.Email,
recipe.Features.Tests,
recipe.Features.SSO,
} }
if pattern != "" {
if strings.Contains(recipe.Name, pattern) {
table.Append(tableRow)
len++
}
} else {
table.Append(tableRow)
len++
}
}
if table.NumLines() > 0 {
if internal.MachineReadable {
table.SetCaption(false, "")
table.JSONRender()
} else {
table.SetCaption(true, fmt.Sprintf("total recipes: %v", len))
table.Render() table.Render()
}
}
return nil return nil
}, },

View File

@ -1,10 +1,8 @@
package recipe package recipe
import ( import (
"bytes"
"errors" "errors"
"fmt" "fmt"
"io/ioutil"
"os" "os"
"path" "path"
"text/template" "text/template"
@ -13,55 +11,37 @@ import (
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/git" "coopcloud.tech/abra/pkg/git"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
// recipeMetadata is the recipe metadata for the README.md var recipeNewCommand = &cli.Command{
type recipeMetadata struct {
Name string
Description string
Category string
Status string
Image string
Healthcheck string
Backups string
Email string
Tests string
SSO string
}
var recipeNewCommand = cli.Command{
Name: "new", Name: "new",
Aliases: []string{"n"},
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.OfflineFlag,
},
Before: internal.SubCommandBefore,
Usage: "Create a new recipe", Usage: "Create a new recipe",
Aliases: []string{"n"},
ArgsUsage: "<recipe>", ArgsUsage: "<recipe>",
Description: ` Description: `
Create a new recipe. This command creates a new recipe.
Abra uses the built-in example repository which is available here: Abra uses our built-in example repository which is available here:
https://git.coopcloud.tech/coop-cloud/example https://git.coopcloud.tech/coop-cloud/example
Files within the example repository make use of the Golang templating system Files within the example repository make use of the Golang templating system
which Abra uses to inject values into the generated recipe folder (e.g. name of which Abra uses to inject values into the generated recipe folder (e.g. name of
recipe and domain in the sample environment config). recipe and domain in the sample environment config).
The new example repository is cloned to ~/.abra/apps/<recipe>.
`, `,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
recipeName := c.Args().First() recipeName := c.Args().First()
if recipeName == "" { if recipeName == "" {
internal.ShowSubcommandHelpAndError(c, errors.New("no recipe name provided")) internal.ShowSubcommandHelpAndError(c, errors.New("no recipe provided"))
} }
directory := path.Join(config.RECIPES_DIR, recipeName) directory := path.Join(config.APPS_DIR, recipeName)
if _, err := os.Stat(directory); !os.IsNotExist(err) { if _, err := os.Stat(directory); !os.IsNotExist(err) {
logrus.Fatalf("%s recipe directory already exists?", directory) logrus.Fatalf("'%s' recipe directory already exists?", directory)
} }
url := fmt.Sprintf("%s/example.git", config.REPOS_BASE_URL) url := fmt.Sprintf("%s/example.git", config.REPOS_BASE_URL)
@ -69,73 +49,44 @@ recipe and domain in the sample environment config).
logrus.Fatal(err) logrus.Fatal(err)
} }
gitRepo := path.Join(config.RECIPES_DIR, recipeName, ".git") gitRepo := path.Join(config.APPS_DIR, recipeName, ".git")
if err := os.RemoveAll(gitRepo); err != nil { if err := os.RemoveAll(gitRepo); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Debugf("removed example git repo in %s", gitRepo) logrus.Debugf("removed git repo in '%s'", gitRepo)
meta := newRecipeMeta(recipeName)
toParse := []string{ toParse := []string{
path.Join(config.RECIPES_DIR, recipeName, "README.md"), path.Join(config.APPS_DIR, recipeName, "README.md"),
path.Join(config.RECIPES_DIR, recipeName, ".env.sample"), path.Join(config.APPS_DIR, recipeName, ".env.sample"),
path.Join(config.APPS_DIR, recipeName, ".drone.yml"),
} }
for _, path := range toParse { for _, path := range toParse {
file, err := os.OpenFile(path, os.O_RDWR, 0755)
if err != nil {
logrus.Fatal(err)
}
tpl, err := template.ParseFiles(path) tpl, err := template.ParseFiles(path)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
var templated bytes.Buffer // TODO: ask for description and probably other things so that the
if err := tpl.Execute(&templated, meta); err != nil { // template repository is more "ready" to go than the current best-guess
// mode of templating
if err := tpl.Execute(file, struct {
Name string
Description string
}{recipeName, "TODO"}); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if err := ioutil.WriteFile(path, templated.Bytes(), 0644); err != nil {
logrus.Fatal(err)
} }
} logrus.Infof(
"new recipe '%s' created in %s, happy hacking!\n",
newGitRepo := path.Join(config.RECIPES_DIR, recipeName) recipeName, path.Join(config.APPS_DIR, recipeName),
if err := git.Init(newGitRepo, true); err != nil { )
logrus.Fatal(err)
}
fmt.Print(fmt.Sprintf(`
Your new %s recipe has been created in %s.
In order to share your recipe, you can upload it the git repository to:
https://git.coopcloud.tech/coop-cloud/%s
If you're not sure how to do that, come chat with us:
https://docs.coopcloud.tech/intro/contact
See "abra recipe -h" for additional recipe maintainer commands.
Happy Hacking!
`, recipeName, path.Join(config.RECIPES_DIR, recipeName), recipeName))
return nil return nil
}, },
} }
// newRecipeMeta creates a new recipeMetadata instance with defaults
func newRecipeMeta(recipeName string) recipeMetadata {
return recipeMetadata{
Name: recipeName,
Description: "> One line description of the recipe",
Category: "Apps",
Status: "0",
Image: fmt.Sprintf("[`%s`](https://hub.docker.com/r/%s), 4, upstream", recipeName, recipeName),
Healthcheck: "No",
Backups: "No",
Email: "No",
Tests: "No",
SSO: "No",
}
}

View File

@ -1,34 +1,27 @@
package recipe package recipe
import ( import (
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
// RecipeCommand defines all recipe related sub-commands. // RecipeCommand defines all recipe related sub-commands.
var RecipeCommand = cli.Command{ var RecipeCommand = &cli.Command{
Name: "recipe", Name: "recipe",
Aliases: []string{"r"}, Usage: "Manage recipes (for maintainers)",
Usage: "Manage recipes",
ArgsUsage: "<recipe>", ArgsUsage: "<recipe>",
Aliases: []string{"r"},
Description: ` Description: `
A recipe is a blueprint for an app. It is a bunch of config files which A recipe is a blueprint for an app. It is a bunch of configuration files which
describe how to deploy and maintain an app. Recipes are maintained by the Co-op describe how to deploy and maintain an app. Recipes are maintained by the Co-op
Cloud community and you can use Abra to read them, deploy them and create apps Cloud community and you can use Abra to read them and create apps for you.
for you.
Anyone who uses a recipe can become a maintainer. Maintainers typically make
sure the recipe is in good working order and the config upgraded in a timely
manner. Abra supports convenient automation for recipe maintainenace, see the
"abra recipe upgrade", "abra recipe sync" and "abra recipe release" commands.
`, `,
Subcommands: []cli.Command{ Subcommands: []*cli.Command{
recipeFetchCommand,
recipeLintCommand,
recipeListCommand, recipeListCommand,
recipeNewCommand,
recipeReleaseCommand,
recipeSyncCommand,
recipeUpgradeCommand,
recipeVersionCommand, recipeVersionCommand,
recipeReleaseCommand,
recipeNewCommand,
recipeUpgradeCommand,
recipeSyncCommand,
recipeLintCommand,
}, },
} }

View File

@ -6,120 +6,290 @@ import (
"strconv" "strconv"
"strings" "strings"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
gitPkg "coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/recipe"
recipePkg "coopcloud.tech/abra/pkg/recipe" recipePkg "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/tagcmp" "coopcloud.tech/tagcmp"
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
"github.com/docker/distribution/reference" "github.com/docker/distribution/reference"
"github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var recipeReleaseCommand = cli.Command{ var recipeReleaseCommand = &cli.Command{
Name: "release", Name: "release",
Aliases: []string{"rl"},
Usage: "Release a new recipe version", Usage: "Release a new recipe version",
Aliases: []string{"rl"},
ArgsUsage: "<recipe> [<version>]", ArgsUsage: "<recipe> [<version>]",
Description: ` Description: `
Create a new version of a recipe. These versions are then published on the This command is used to specify a new tag for a recipe. These tags are used to
Co-op Cloud recipe catalogue. These versions take the following form: identify different versions of the recipe and are published on the Co-op Cloud
recipe catalogue.
These tags take the following form:
a.b.c+x.y.z a.b.c+x.y.z
Where the "a.b.c" part is a semantic version determined by the maintainer. The Where the "a.b.c" part is maintained as a semantic version of the recipe by the
"x.y.z" part is the image tag of the recipe "app" service (the main container recipe maintainer. And the "x.y.z" part is the image tag of the recipe "app"
which contains the software to be used, by naming convention). service (the main container which contains the software to be used).
We maintain a semantic versioning scheme ("a.b.c") alongside the recipe We maintain a semantic versioning scheme ("a.b.c") alongside the libre app
versioning scheme ("x.y.z") in order to maximise the chances that the nature of versioning scheme in order to maximise the chances that the nature of recipe
recipe updates are properly communicated. I.e. developers of an app might updates are properly communicated.
publish a minor version but that might lead to changes in the recipe which are
major and therefore require intervention while doing the upgrade work. Abra does its best to read the "a.b.c" version scheme and communicate what
action needs to be taken when performing different operations such as an update
or a rollback of an app.
You may invoke this command in "wizard" mode and be prompted for input:
abra recipe release gitea
Publish your new release to git.coopcloud.tech with "-p/--publish". This
requires that you have permission to git push to these repositories and have
your SSH keys configured on your account.
`, `,
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.DryFlag, internal.DryFlag,
internal.MajorFlag, internal.MajorFlag,
internal.MinorFlag, internal.MinorFlag,
internal.PatchFlag, internal.PatchFlag,
internal.PublishFlag, internal.PushFlag,
internal.OfflineFlag, internal.CommitFlag,
internal.CommitMessageFlag,
internal.TagMessageFlag,
}, },
Before: internal.SubCommandBefore,
BashComplete: autocomplete.RecipeNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipe(c) recipe := internal.ValidateRecipeWithPrompt(c)
directory := path.Join(config.APPS_DIR, recipe.Name)
tagString := c.Args().Get(1)
mainApp := internal.GetMainApp(recipe)
imagesTmp, err := getImageVersions(recipe) imagesTmp, err := getImageVersions(recipe)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
mainAppVersion := imagesTmp[mainApp]
mainApp, err := internal.GetMainAppImage(recipe) if err := recipePkg.EnsureExists(recipe.Name); err != nil {
if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
mainAppVersion := imagesTmp[mainApp]
if mainAppVersion == "" { if mainAppVersion == "" {
logrus.Fatalf("main app service version for %s is empty?", recipe.Name) logrus.Fatalf("main 'app' service version for %s is empty?", recipe.Name)
} }
tagString := c.Args().Get(1)
if tagString != "" { if tagString != "" {
if _, err := tagcmp.Parse(tagString); err != nil { if _, err := tagcmp.Parse(tagString); err != nil {
logrus.Fatalf("cannot parse %s, invalid tag specified?", tagString) logrus.Fatal("invalid tag specified")
} }
} }
if (!internal.Major && !internal.Minor && !internal.Patch) && tagString != "" {
logrus.Fatal("please specify <version> or bump type (--major/--minor/--patch)")
}
if (internal.Major || internal.Minor || internal.Patch) && tagString != "" { if (internal.Major || internal.Minor || internal.Patch) && tagString != "" {
logrus.Fatal("cannot specify tag and bump type at the same time") logrus.Fatal("cannot specify tag and bump type at the same time")
} }
if tagString != "" { // bumpType is used to decide what part of the tag should be incremented
if err := createReleaseFromTag(recipe, tagString, mainAppVersion); err != nil { bumpType := btoi(internal.Major)*4 + btoi(internal.Minor)*2 + btoi(internal.Patch)
if bumpType != 0 {
// a bitwise check if the number is a power of 2
if (bumpType & (bumpType - 1)) != 0 {
logrus.Fatal("you can only use one of: --major, --minor, --patch.")
}
}
if err := internal.PromptBumpType(tagString); err != nil {
logrus.Fatal(err)
}
if internal.TagMessage == "" {
prompt := &survey.Input{
Message: "tag message",
Default: fmt.Sprintf("chore: publish new %s version", internal.GetBumpType()),
}
if err := survey.AskOne(prompt, &internal.TagMessage); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} }
tags, err := recipe.Tags() var createTagOptions git.CreateTagOptions
createTagOptions.Message = internal.TagMessage
if !internal.Commit {
prompt := &survey.Confirm{
Message: "git commit changes also?",
}
if err := survey.AskOne(prompt, &internal.Commit); err != nil {
return err
}
}
if !internal.Push {
prompt := &survey.Confirm{
Message: "git push changes also?",
}
if err := survey.AskOne(prompt, &internal.Push); err != nil {
return err
}
}
if internal.Commit || internal.CommitMessage != "" {
commitRepo, err := git.PlainOpen(directory)
if err != nil {
logrus.Fatal(err)
}
commitWorktree, err := commitRepo.Worktree()
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if tagString == "" && (!internal.Major && !internal.Minor && !internal.Patch) { if internal.CommitMessage == "" {
var err error prompt := &survey.Input{
tagString, err = getLabelVersion(recipe, false) Message: "commit message",
if err != nil { Default: fmt.Sprintf("chore: publish new %s version", internal.GetBumpType()),
}
if err := survey.AskOne(prompt, &internal.CommitMessage); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} }
if len(tags) > 0 { err = commitWorktree.AddGlob("compose.**yml")
logrus.Warnf("previous git tags detected, assuming this is a new semver release") if err != nil {
if err := createReleaseFromPreviousTag(tagString, mainAppVersion, recipe, tags); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Debug("staged compose.**yml for commit")
if !internal.Dry {
_, err = commitWorktree.Commit(internal.CommitMessage, &git.CommitOptions{})
if err != nil {
logrus.Fatal(err)
}
logrus.Info("changes commited")
} else { } else {
logrus.Warnf("no tag specified and no previous tag available for %s, assuming this is the initial release", recipe.Name) logrus.Info("dry run only: NOT committing changes")
if err := createReleaseFromTag(recipe, tagString, mainAppVersion); err != nil {
if cleanUpErr := cleanUpTag(tagString, recipe.Name); err != nil {
logrus.Fatal(cleanUpErr)
} }
}
repo, err := git.PlainOpen(directory)
if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
head, err := repo.Head()
if err != nil {
logrus.Fatal(err)
}
if tagString != "" {
tag, err := tagcmp.Parse(tagString)
if err != nil {
logrus.Fatal(err)
}
if tag.MissingMinor {
tag.Minor = "0"
tag.MissingMinor = false
}
if tag.MissingPatch {
tag.Patch = "0"
tag.MissingPatch = false
}
tagString = fmt.Sprintf("%s+%s", tag.String(), mainAppVersion)
if internal.Dry {
hash := abraFormatter.SmallSHA(head.Hash().String())
logrus.Info(fmt.Sprintf("dry run only: NOT creating tag %s at %s", tagString, hash))
return nil
}
repo.CreateTag(tagString, head.Hash(), &createTagOptions)
hash := abraFormatter.SmallSHA(head.Hash().String())
logrus.Info(fmt.Sprintf("created tag %s at %s", tagString, hash))
if internal.Push && !internal.Dry {
if err := repo.Push(&git.PushOptions{}); err != nil {
logrus.Fatal(err)
}
logrus.Info(fmt.Sprintf("pushed tag %s to remote", tagString))
} else {
logrus.Info("dry run only: NOT pushing changes")
}
return nil
}
// get the latest tag with its hash, name etc
var lastGitTag tagcmp.Tag
iter, err := repo.Tags()
if err != nil {
logrus.Fatal(err)
}
if err := iter.ForEach(func(ref *plumbing.Reference) error {
obj, err := repo.TagObject(ref.Hash())
if err != nil {
return err
}
tagcmpTag, err := tagcmp.Parse(obj.Name)
if err != nil {
return err
}
if (lastGitTag == tagcmp.Tag{}) {
lastGitTag = tagcmpTag
} else if tagcmpTag.IsGreaterThan(lastGitTag) {
lastGitTag = tagcmpTag
}
return nil
}); err != nil {
logrus.Fatal(err)
}
newTag := lastGitTag
var newtagString string
if bumpType > 0 {
if internal.Patch {
now, err := strconv.Atoi(newTag.Patch)
if err != nil {
logrus.Fatal(err)
}
newTag.Patch = strconv.Itoa(now + 1)
} else if internal.Minor {
now, err := strconv.Atoi(newTag.Minor)
if err != nil {
logrus.Fatal(err)
}
newTag.Patch = "0"
newTag.Minor = strconv.Itoa(now + 1)
} else if internal.Major {
now, err := strconv.Atoi(newTag.Major)
if err != nil {
logrus.Fatal(err)
}
newTag.Patch = "0"
newTag.Minor = "0"
newTag.Major = strconv.Itoa(now + 1)
}
}
newTag.Metadata = mainAppVersion
newtagString = newTag.String()
if internal.Dry {
hash := abraFormatter.SmallSHA(head.Hash().String())
logrus.Info(fmt.Sprintf("dry run only: NOT creating tag %s at %s", newtagString, hash))
return nil
}
repo.CreateTag(newtagString, head.Hash(), &createTagOptions)
hash := abraFormatter.SmallSHA(head.Hash().String())
logrus.Info(fmt.Sprintf("created tag %s at %s", newtagString, hash))
if internal.Push && !internal.Dry {
if err := repo.Push(&git.PushOptions{}); err != nil {
logrus.Fatal(err)
}
logrus.Info(fmt.Sprintf("pushed tag %s to remote", newtagString))
} else {
logrus.Info("gry run only: NOT pushing changes")
} }
return nil return nil
@ -130,7 +300,6 @@ your SSH keys configured on your account.
func getImageVersions(recipe recipe.Recipe) (map[string]string, error) { func getImageVersions(recipe recipe.Recipe) (map[string]string, error) {
var services = make(map[string]string) var services = make(map[string]string)
missingTag := false
for _, service := range recipe.Config.Services { for _, service := range recipe.Config.Services {
if service.Image == "" { if service.Image == "" {
continue continue
@ -142,74 +311,24 @@ func getImageVersions(recipe recipe.Recipe) (map[string]string, error) {
} }
path := reference.Path(img) path := reference.Path(img)
if strings.Contains(path, "library") {
path = formatter.StripTagMeta(path) path = strings.Split(path, "/")[1]
}
var tag string var tag string
switch img.(type) { switch img.(type) {
case reference.NamedTagged: case reference.NamedTagged:
tag = img.(reference.NamedTagged).Tag() tag = img.(reference.NamedTagged).Tag()
case reference.Named: case reference.Named:
if service.Name == "app" { logrus.Fatalf("%s service is missing image tag?", path)
missingTag = true
}
continue
} }
services[path] = tag services[path] = tag
} }
if missingTag {
return services, fmt.Errorf("app service is missing image tag?")
}
return services, nil return services, nil
} }
// createReleaseFromTag creates a new release based on a supplied recipe version string
func createReleaseFromTag(recipe recipe.Recipe, tagString, mainAppVersion string) error {
var err error
directory := path.Join(config.RECIPES_DIR, recipe.Name)
repo, err := git.PlainOpen(directory)
if err != nil {
return err
}
tag, err := tagcmp.Parse(tagString)
if err != nil {
return err
}
if tag.MissingMinor {
tag.Minor = "0"
tag.MissingMinor = false
}
if tag.MissingPatch {
tag.Patch = "0"
tag.MissingPatch = false
}
if tagString == "" {
tagString = fmt.Sprintf("%s+%s", tag.String(), mainAppVersion)
}
if err := commitRelease(recipe, tagString); err != nil {
logrus.Fatal(err)
}
if err := tagRelease(tagString, repo); err != nil {
logrus.Fatal(err)
}
if err := pushRelease(recipe, tagString); err != nil {
logrus.Fatal(err)
}
return nil
}
// btoi converts a boolean value into an integer // btoi converts a boolean value into an integer
func btoi(b bool) int { func btoi(b bool) int {
if b { if b {
@ -218,237 +337,3 @@ func btoi(b bool) int {
return 0 return 0
} }
// getTagCreateOptions constructs git tag create options
func getTagCreateOptions(tag string) (git.CreateTagOptions, error) {
msg := fmt.Sprintf("chore: publish %s release", tag)
return git.CreateTagOptions{Message: msg}, nil
}
func commitRelease(recipe recipe.Recipe, tag string) error {
if internal.Dry {
logrus.Debugf("dry run: no changes committed")
return nil
}
isClean, err := gitPkg.IsClean(recipe.Dir())
if err != nil {
return err
}
if isClean {
if !internal.Dry {
return fmt.Errorf("no changes discovered in %s, nothing to publish?", recipe.Dir())
}
}
msg := fmt.Sprintf("chore: publish %s release", tag)
repoPath := path.Join(config.RECIPES_DIR, recipe.Name)
if err := gitPkg.Commit(repoPath, ".", msg, internal.Dry); err != nil {
return err
}
return nil
}
func tagRelease(tagString string, repo *git.Repository) error {
if internal.Dry {
logrus.Debugf("dry run: no git tag created (%s)", tagString)
return nil
}
head, err := repo.Head()
if err != nil {
return err
}
createTagOptions, err := getTagCreateOptions(tagString)
if err != nil {
return err
}
_, err = repo.CreateTag(tagString, head.Hash(), &createTagOptions)
if err != nil {
return err
}
hash := formatter.SmallSHA(head.Hash().String())
logrus.Debugf(fmt.Sprintf("created tag %s at %s", tagString, hash))
return nil
}
func pushRelease(recipe recipe.Recipe, tagString string) error {
if internal.Dry {
logrus.Info("dry run: no changes published")
return nil
}
if !internal.Publish && !internal.NoInput {
prompt := &survey.Confirm{
Message: "publish new release?",
}
if err := survey.AskOne(prompt, &internal.Publish); err != nil {
return err
}
}
if internal.Publish {
if err := recipe.Push(internal.Dry); err != nil {
return err
}
url := fmt.Sprintf("%s/%s/src/tag/%s", config.REPOS_BASE_URL, recipe.Name, tagString)
logrus.Infof("new release published: %s", url)
} else {
logrus.Info("no -p/--publish passed, not publishing")
}
return nil
}
func createReleaseFromPreviousTag(tagString, mainAppVersion string, recipe recipe.Recipe, tags []string) error {
directory := path.Join(config.RECIPES_DIR, recipe.Name)
repo, err := git.PlainOpen(directory)
if err != nil {
return err
}
bumpType := btoi(internal.Major)*4 + btoi(internal.Minor)*2 + btoi(internal.Patch)
if bumpType != 0 {
if (bumpType & (bumpType - 1)) != 0 {
return fmt.Errorf("you can only use one of: --major, --minor, --patch")
}
}
var lastGitTag tagcmp.Tag
for _, tag := range tags {
parsed, err := tagcmp.Parse(tag)
if err != nil {
return err
}
if (lastGitTag == tagcmp.Tag{}) {
lastGitTag = parsed
} else if parsed.IsGreaterThan(lastGitTag) {
lastGitTag = parsed
}
}
newTag := lastGitTag
if internal.Patch {
now, err := strconv.Atoi(newTag.Patch)
if err != nil {
return err
}
newTag.Patch = strconv.Itoa(now + 1)
} else if internal.Minor {
now, err := strconv.Atoi(newTag.Minor)
if err != nil {
return err
}
newTag.Patch = "0"
newTag.Minor = strconv.Itoa(now + 1)
} else if internal.Major {
now, err := strconv.Atoi(newTag.Major)
if err != nil {
return err
}
newTag.Patch = "0"
newTag.Minor = "0"
newTag.Major = strconv.Itoa(now + 1)
}
if tagString == "" {
if err := internal.PromptBumpType(tagString, lastGitTag.String()); err != nil {
return err
}
}
if internal.Major || internal.Minor || internal.Patch {
newTag.Metadata = mainAppVersion
tagString = newTag.String()
}
if lastGitTag.String() == tagString {
logrus.Fatalf("latest git tag (%s) and synced label (%s) are the same?", lastGitTag, tagString)
}
if !internal.NoInput {
prompt := &survey.Confirm{
Message: fmt.Sprintf("current: %s, new: %s, correct?", lastGitTag, tagString),
}
var ok bool
if err := survey.AskOne(prompt, &ok); err != nil {
logrus.Fatal(err)
}
if !ok {
logrus.Fatal("exiting as requested")
}
}
if err := commitRelease(recipe, tagString); err != nil {
logrus.Fatalf("failed to commit changes: %s", err.Error())
}
if err := tagRelease(tagString, repo); err != nil {
logrus.Fatalf("failed to tag release: %s", err.Error())
}
if err := pushRelease(recipe, tagString); err != nil {
logrus.Fatalf("failed to publish new release: %s", err.Error())
}
return nil
}
// cleanUpTag removes a freshly created tag
func cleanUpTag(tag, recipeName string) error {
directory := path.Join(config.RECIPES_DIR, recipeName)
repo, err := git.PlainOpen(directory)
if err != nil {
return err
}
if err := repo.DeleteTag(tag); err != nil {
if !strings.Contains(err.Error(), "not found") {
return err
}
}
logrus.Debugf("removed freshly created tag %s", tag)
return nil
}
func getLabelVersion(recipe recipe.Recipe, prompt bool) (string, error) {
initTag, err := recipePkg.GetVersionLabelLocal(recipe)
if err != nil {
return "", err
}
if initTag == "" {
logrus.Fatalf("unable to read version for %s from synced label. Did you try running \"abra recipe sync %s\" already?", recipe.Name, recipe.Name)
}
logrus.Warnf("discovered %s as currently synced recipe label", initTag)
if prompt && !internal.NoInput {
var response bool
prompt := &survey.Confirm{Message: fmt.Sprintf("use %s as the new version?", initTag)}
if err := survey.AskOne(prompt, &response); err != nil {
return "", err
}
if !response {
return "", fmt.Errorf("please fix your synced label for %s and re-run this command", recipe.Name)
}
}
return initTag, nil
}

View File

@ -6,54 +6,51 @@ import (
"strconv" "strconv"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/tagcmp" "coopcloud.tech/tagcmp"
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
"github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var recipeSyncCommand = cli.Command{ var recipeSyncCommand = &cli.Command{
Name: "sync", Name: "sync",
Usage: "Ensure recipe version labels are up-to-date",
Aliases: []string{"s"}, Aliases: []string{"s"},
Usage: "Sync recipe version label",
ArgsUsage: "<recipe> [<version>]", ArgsUsage: "<recipe> [<version>]",
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.DryFlag, internal.DryFlag,
internal.MajorFlag, internal.MajorFlag,
internal.MinorFlag, internal.MinorFlag,
internal.PatchFlag, internal.PatchFlag,
}, },
Before: internal.SubCommandBefore,
Description: ` Description: `
Generate labels for the main recipe service (i.e. by convention, the service This command will generate labels for the main recipe service (i.e. by
named "app") which corresponds to the following format: convention, the service named "app") which corresponds to the following format:
coop-cloud.${STACK_NAME}.version=<version> coop-cloud.${STACK_NAME}.version=<version>
Where <version> can be specifed on the command-line or Abra can attempt to The <version> is determined by the recipe maintainer and is specified on the
auto-generate it for you. The <recipe> configuration will be updated on the command-line. The <recipe> configuration will be updated on the local file
local file system. system.
`,
BashComplete: autocomplete.RecipeNameComplete,
Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipe(c)
mainApp, err := internal.GetMainAppImage(recipe) You may invoke this command in "wizard" mode and be prompted for input:
if err != nil {
logrus.Fatal(err) abra recipe sync gitea
}
`,
Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipeWithPrompt(c)
mainApp := internal.GetMainApp(recipe)
imagesTmp, err := getImageVersions(recipe) imagesTmp, err := getImageVersions(recipe)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
mainAppVersion := imagesTmp[mainApp] mainAppVersion := imagesTmp[mainApp]
tags, err := recipe.Tags() tags, err := recipe.Tags()
@ -63,79 +60,49 @@ local file system.
nextTag := c.Args().Get(1) nextTag := c.Args().Get(1)
if len(tags) == 0 && nextTag == "" { if len(tags) == 0 && nextTag == "" {
logrus.Warnf("no git tags found for %s", recipe.Name) logrus.Warnf("no tags found for %s", recipe.Name)
if internal.NoInput {
logrus.Fatalf("unable to continue, input required for initial version")
}
fmt.Println(fmt.Sprintf(`
The following options are two types of initial semantic version that you can
pick for %s that will be published in the recipe catalogue. This follows the
semver convention (more on https://semver.org), here is a short cheatsheet
0.1.0: development release, still hacking. when you make a major upgrade
you increment the "y" part (i.e. 0.1.0 -> 0.2.0) and only move to
using the "x" part when things are stable.
1.0.0: public release, assumed to be working. you already have a stable
and reliable deployment of this app and feel relatively confident
about it.
If you want people to be able alpha test your current config for %s but don't
think it is quite reliable, go with 0.1.0 and people will know that things are
likely to change.
`, recipe.Name, recipe.Name))
var chosenVersion string var chosenVersion string
edPrompt := &survey.Select{ edPrompt := &survey.Select{
Message: "which version do you want to begin with?", Message: "which version do you want to begin with?",
Options: []string{"0.1.0", "1.0.0"}, Options: []string{"0.1.0", "1.0.0"},
} }
if err := survey.AskOne(edPrompt, &chosenVersion); err != nil { if err := survey.AskOne(edPrompt, &chosenVersion); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
nextTag = fmt.Sprintf("%s+%s", chosenVersion, mainAppVersion) nextTag = fmt.Sprintf("%s+%s", chosenVersion, mainAppVersion)
} }
if nextTag == "" && (!internal.Major && !internal.Minor && !internal.Patch) { if nextTag == "" && (!internal.Major && !internal.Minor && !internal.Patch) {
latestRelease := tags[len(tags)-1] if err := internal.PromptBumpType(""); err != nil {
if err := internal.PromptBumpType("", latestRelease); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} }
if nextTag == "" { if nextTag == "" {
recipeDir := path.Join(config.RECIPES_DIR, recipe.Name) recipeDir := path.Join(config.APPS_DIR, recipe.Name)
repo, err := git.PlainOpen(recipeDir) repo, err := git.PlainOpen(recipeDir)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
var lastGitTag tagcmp.Tag var lastGitTag tagcmp.Tag
iter, err := repo.Tags() iter, err := repo.Tags()
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if err := iter.ForEach(func(ref *plumbing.Reference) error { if err := iter.ForEach(func(ref *plumbing.Reference) error {
obj, err := repo.TagObject(ref.Hash()) obj, err := repo.TagObject(ref.Hash())
if err != nil { if err != nil {
logrus.Fatal("Tag at commit ", ref.Hash(), " is unannotated or otherwise broken. Please fix it.")
return err return err
} }
tagcmpTag, err := tagcmp.Parse(obj.Name) tagcmpTag, err := tagcmp.Parse(obj.Name)
if err != nil { if err != nil {
return err return err
} }
if (lastGitTag == tagcmp.Tag{}) { if (lastGitTag == tagcmp.Tag{}) {
lastGitTag = tagcmpTag lastGitTag = tagcmpTag
} else if tagcmpTag.IsGreaterThan(lastGitTag) { } else if tagcmpTag.IsGreaterThan(lastGitTag) {
lastGitTag = tagcmpTag lastGitTag = tagcmpTag
} }
return nil return nil
}); err != nil { }); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
@ -146,7 +113,7 @@ likely to change.
if bumpType != 0 { if bumpType != 0 {
// a bitwise check if the number is a power of 2 // a bitwise check if the number is a power of 2
if (bumpType & (bumpType - 1)) != 0 { if (bumpType & (bumpType - 1)) != 0 {
logrus.Fatal("you can only use one version flag: --major, --minor or --patch") logrus.Fatal("you can only use one of: --major, --minor, --patch.")
} }
} }
@ -157,14 +124,12 @@ likely to change.
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
newTag.Patch = strconv.Itoa(now + 1) newTag.Patch = strconv.Itoa(now + 1)
} else if internal.Minor { } else if internal.Minor {
now, err := strconv.Atoi(newTag.Minor) now, err := strconv.Atoi(newTag.Minor)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
newTag.Patch = "0" newTag.Patch = "0"
newTag.Minor = strconv.Itoa(now + 1) newTag.Minor = strconv.Itoa(now + 1)
} else if internal.Major { } else if internal.Major {
@ -172,7 +137,6 @@ likely to change.
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
newTag.Patch = "0" newTag.Patch = "0"
newTag.Minor = "0" newTag.Minor = "0"
newTag.Major = strconv.Itoa(now + 1) newTag.Major = strconv.Itoa(now + 1)
@ -189,15 +153,44 @@ likely to change.
} }
mainService := "app" mainService := "app"
var services []string
hasAppService := false
for _, service := range recipe.Config.Services {
services = append(services, service.Name)
if service.Name == "app" {
hasAppService = true
logrus.Debugf("detected app service in %s", recipe.Name)
}
}
if !hasAppService {
logrus.Fatalf("%s has no main 'app' service?", recipe.Name)
}
logrus.Debugf("selecting %s as the service to sync version label", mainService)
label := fmt.Sprintf("coop-cloud.${STACK_NAME}.version=%s", nextTag) label := fmt.Sprintf("coop-cloud.${STACK_NAME}.version=%s", nextTag)
if !internal.Dry { if !internal.Dry {
if err := recipe.UpdateLabel("compose.y*ml", mainService, label); err != nil { if err := recipe.UpdateLabel(mainService, label); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Infof("synced label '%s' to service '%s'", label, mainService)
} else { } else {
logrus.Infof("dry run: not syncing label %s for recipe %s", nextTag, recipe.Name) logrus.Infof("dry run only: NOT syncing label %s for recipe %s", nextTag, recipe.Name)
} }
return nil return nil
}, },
BashComplete: func(c *cli.Context) {
catl, err := catalogue.ReadRecipeCatalogue()
if err != nil {
logrus.Warn(err)
}
if c.NArg() > 0 {
return
}
for name := range catl {
fmt.Println(name)
}
},
} }

View File

@ -2,7 +2,6 @@ package recipe
import ( import (
"bufio" "bufio"
"encoding/json"
"fmt" "fmt"
"os" "os"
"path" "path"
@ -10,16 +9,14 @@ import (
"strings" "strings"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/tagcmp" "coopcloud.tech/tagcmp"
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
"github.com/docker/distribution/reference" "github.com/docker/distribution/reference"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
type imgPin struct { type imgPin struct {
@ -27,67 +24,28 @@ type imgPin struct {
version tagcmp.Tag version tagcmp.Tag
} }
// anUpgrade represents a single service upgrade (as within a recipe), and the list of tags that it can be upgraded to, var recipeUpgradeCommand = &cli.Command{
// for serialization purposes.
type anUpgrade struct {
Service string `json:"service"`
Image string `json:"image"`
Tag string `json:"tag"`
UpgradeTags []string `json:"upgrades"`
}
var recipeUpgradeCommand = cli.Command{
Name: "upgrade", Name: "upgrade",
Aliases: []string{"u"},
Usage: "Upgrade recipe image tags", Usage: "Upgrade recipe image tags",
Aliases: []string{"u"},
Description: ` Description: `
Parse all image tags within the given <recipe> configuration and prompt with This command reads and attempts to parse all image tags within the given
more recent tags to upgrade to. It will update the relevant compose file tags <recipe> configuration and prompt with more recent tags to upgrade to. It will
on the local file system. update the relevant compose file tags on the local file system.
Some image tags cannot be parsed because they do not follow some sort of Some image tags cannot be parsed because they do not follow some sort of
semver-like convention. In this case, all possible tags will be listed and it semver-like convention. In this case, all possible tags will be listed and it
is up to the end-user to decide. is up to the end-user to decide.
The command is interactive and will show a select input which allows you to
make a seclection. Use the "?" key to see more help on navigating this
interface.
You may invoke this command in "wizard" mode and be prompted for input:
abra recipe upgrade
`, `,
ArgsUsage: "<recipe>", ArgsUsage: "<recipe>",
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.PatchFlag, internal.PatchFlag,
internal.MinorFlag, internal.MinorFlag,
internal.MajorFlag, internal.MajorFlag,
internal.MachineReadableFlag,
internal.AllTagsFlag,
}, },
Before: internal.SubCommandBefore,
BashComplete: autocomplete.RecipeNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipe(c) recipe := internal.ValidateRecipe(c)
if err := recipePkg.EnsureIsClean(recipe.Name); err != nil {
logrus.Fatal(err)
}
if err := recipePkg.EnsureExists(recipe.Name); err != nil {
logrus.Fatal(err)
}
if err := recipePkg.EnsureUpToDate(recipe.Name); err != nil {
logrus.Fatal(err)
}
if err := recipePkg.EnsureLatest(recipe.Name); err != nil {
logrus.Fatal(err)
}
bumpType := btoi(internal.Major)*4 + btoi(internal.Minor)*2 + btoi(internal.Patch) bumpType := btoi(internal.Major)*4 + btoi(internal.Minor)*2 + btoi(internal.Patch)
if bumpType != 0 { if bumpType != 0 {
// a bitwise check if the number is a power of 2 // a bitwise check if the number is a power of 2
@ -96,16 +54,9 @@ You may invoke this command in "wizard" mode and be prompted for input:
} }
} }
if internal.MachineReadable {
// -m implies -n in this case
internal.NoInput = true
}
upgradeList := make(map[string]anUpgrade)
// check for versions file and load pinned versions // check for versions file and load pinned versions
versionsPresent := false versionsPresent := false
recipeDir := path.Join(config.RECIPES_DIR, recipe.Name) recipeDir := path.Join(config.ABRA_DIR, "apps", recipe.Name)
versionsPath := path.Join(recipeDir, "versions") versionsPath := path.Join(recipeDir, "versions")
var servicePins = make(map[string]imgPin) var servicePins = make(map[string]imgPin)
if _, err := os.Stat(versionsPath); err == nil { if _, err := os.Stat(versionsPath); err == nil {
@ -141,41 +92,43 @@ You may invoke this command in "wizard" mode and be prompted for input:
} }
for _, service := range recipe.Config.Services { for _, service := range recipe.Config.Services {
catlVersions, err := catalogue.VersionsOfService(recipe.Name, service.Name)
if err != nil {
logrus.Fatal(err)
}
img, err := reference.ParseNormalizedNamed(service.Image) img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
regVersions, err := client.GetRegistryTags(img) image := reference.Path(img)
regVersions, err := client.GetRegistryTags(image)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Debugf("retrieved '%s' from remote registry for '%s'", regVersions, image)
image := reference.Path(img) if strings.Contains(image, "library") {
logrus.Debugf("retrieved %s from remote registry for %s", regVersions, image) // ParseNormalizedNamed prepends 'library' to images like nginx:<tag>,
image = formatter.StripTagMeta(image) // postgres:<tag>, i.e. images which do not have a username in the
// first position of the string
switch img.(type) { image = strings.Split(image, "/")[1]
case reference.NamedTagged:
if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) {
logrus.Debugf("%s not considered semver-like", img.(reference.NamedTagged).Tag())
} }
default: semverLikeTag := true
logrus.Warnf("unable to read tag for image %s, is it missing? skipping upgrade for %s", image, service.Name) if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) {
continue logrus.Debugf("'%s' not considered semver-like", img.(reference.NamedTagged).Tag())
semverLikeTag = false
} }
tag, err := tagcmp.Parse(img.(reference.NamedTagged).Tag()) tag, err := tagcmp.Parse(img.(reference.NamedTagged).Tag())
if err != nil { if err != nil && semverLikeTag {
logrus.Warnf("unable to parse %s, error was: %s, skipping upgrade for %s", image, err.Error(), service.Name) logrus.Fatal(err)
continue
} }
logrus.Debugf("parsed '%s' for '%s'", tag, service.Name)
logrus.Debugf("parsed %s for %s", tag, service.Name)
var compatible []tagcmp.Tag var compatible []tagcmp.Tag
for _, regVersion := range regVersions { for _, regVersion := range regVersions {
other, err := tagcmp.Parse(regVersion) other, err := tagcmp.Parse(regVersion.Name)
if err != nil { if err != nil {
continue // skip tags that cannot be parsed continue // skip tags that cannot be parsed
} }
@ -185,21 +138,16 @@ You may invoke this command in "wizard" mode and be prompted for input:
} }
} }
logrus.Debugf("detected potential upgradable tags %s for %s", compatible, service.Name) logrus.Debugf("detected potential upgradable tags '%s' for '%s'", compatible, service.Name)
sort.Sort(tagcmp.ByTagDesc(compatible)) sort.Sort(tagcmp.ByTagDesc(compatible))
if len(compatible) == 0 && !internal.AllTags { if len(compatible) == 0 && semverLikeTag {
logrus.Info(fmt.Sprintf("no new versions available for %s, assuming %s is the latest (use -a/--all-tags to see all anyway)", image, tag)) logrus.Info(fmt.Sprintf("no new versions available for '%s', '%s' is the latest", image, tag))
continue // skip on to the next tag and don't update any compose files continue // skip on to the next tag and don't update any compose files
} }
catlVersions, err := recipePkg.VersionsOfService(recipe.Name, service.Name, internal.Offline) var compatibleStrings []string
if err != nil {
logrus.Fatal(err)
}
compatibleStrings := []string{"skip"}
for _, compat := range compatible { for _, compat := range compatible {
skip := false skip := false
for _, catlVersion := range catlVersions { for _, catlVersion := range catlVersions {
@ -212,7 +160,7 @@ You may invoke this command in "wizard" mode and be prompted for input:
} }
} }
logrus.Debugf("detected compatible upgradable tags %s for %s", compatibleStrings, service.Name) logrus.Debugf("detected compatible upgradable tags '%s' for '%s'", compatibleStrings, service.Name)
var upgradeTag string var upgradeTag string
_, ok := servicePins[service.Name] _, ok := servicePins[service.Name]
@ -229,13 +177,13 @@ You may invoke this command in "wizard" mode and be prompted for input:
} }
} }
if contains { if contains {
logrus.Infof("upgrading service %s from %s to %s (pinned tag: %s)", service.Name, tag.String(), upgradeTag, pinnedTagString) logrus.Infof("Upgrading service %s from %s to %s (pinned tag: %s)", service.Name, tag.String(), upgradeTag, pinnedTagString)
} else { } else {
logrus.Infof("service %s, image %s pinned to %s, no compatible upgrade found", service.Name, servicePins[service.Name].image, pinnedTagString) logrus.Infof("service %s, image %s pinned to %s, no compatible upgrade found", service.Name, servicePins[service.Name].image, pinnedTagString)
continue continue
} }
} else { } else {
logrus.Fatalf("service %s is at version %s, but pinned to %s, please correct your compose.yml file manually!", service.Name, tag.String(), pinnedTag.String()) logrus.Fatalf("Service %s is at version %s, but pinned to %s, please correct your compose.yml file manually!", service.Name, tag.String(), pinnedTag.String())
continue continue
} }
} else { } else {
@ -252,49 +200,23 @@ You may invoke this command in "wizard" mode and be prompted for input:
} }
} }
if upgradeTag == "" { if upgradeTag == "" {
logrus.Warnf("not upgrading from %s to %s for %s, because the upgrade type is more serious than what user wants", tag.String(), compatible[0].String(), image) logrus.Warnf("not upgrading from '%s' to '%s' for '%s', because the upgrade type is more serious than what user wants.", tag.String(), compatible[0].String(), image)
continue continue
} }
} else { } else {
msg := fmt.Sprintf("upgrade to which tag? (service: %s, image: %s, tag: %s)", service.Name, image, tag) msg := fmt.Sprintf("upgrade to which tag? (service: %s, tag: %s)", service.Name, tag)
if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) || internal.AllTags { if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) {
tag := img.(reference.NamedTagged).Tag() tag := img.(reference.NamedTagged).Tag()
if !internal.AllTags { logrus.Warning(fmt.Sprintf("unable to determine versioning semantics of '%s', listing all tags", tag))
logrus.Warning(fmt.Sprintf("unable to determine versioning semantics of %s, listing all tags", tag))
}
msg = fmt.Sprintf("upgrade to which tag? (service: %s, tag: %s)", service.Name, tag) msg = fmt.Sprintf("upgrade to which tag? (service: %s, tag: %s)", service.Name, tag)
compatibleStrings = []string{"skip"} compatibleStrings = []string{}
for _, regVersion := range regVersions { for _, regVersion := range regVersions {
compatibleStrings = append(compatibleStrings, regVersion) compatibleStrings = append(compatibleStrings, regVersion.Name)
} }
} }
// there is always at least the item "skip" in compatibleStrings (a list of
// possible upgradable tags) and at least one other tag.
upgradableTags := compatibleStrings[1:]
upgrade := anUpgrade{
Service: service.Name,
Image: image,
Tag: tag.String(),
UpgradeTags: make([]string, len(upgradableTags)),
}
for n, s := range upgradableTags {
var sb strings.Builder
if _, err := sb.WriteString(s); err != nil {
}
upgrade.UpgradeTags[n] = sb.String()
}
upgradeList[upgrade.Service] = upgrade
if internal.NoInput {
upgradeTag = "skip"
} else {
prompt := &survey.Select{ prompt := &survey.Select{
Message: msg, Message: msg,
Help: "enter / return to confirm, choose 'skip' to not upgrade this tag, vim mode is enabled",
VimMode: true,
Options: compatibleStrings, Options: compatibleStrings,
} }
if err := survey.AskOne(prompt, &upgradeTag); err != nil { if err := survey.AskOne(prompt, &upgradeTag); err != nil {
@ -302,40 +224,12 @@ You may invoke this command in "wizard" mode and be prompted for input:
} }
} }
} }
} if err := recipe.UpdateTag(image, upgradeTag); err != nil {
if upgradeTag != "skip" {
ok, err := recipe.UpdateTag(image, upgradeTag)
if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if ok { logrus.Infof("tag upgraded from '%s' to '%s' for '%s'", tag.String(), upgradeTag, image)
logrus.Infof("tag upgraded from %s to %s for %s", tag.String(), upgradeTag, image)
}
} else {
if !internal.NoInput {
logrus.Warnf("not upgrading %s, skipping as requested", image)
}
}
} }
if internal.NoInput {
if internal.MachineReadable {
jsonstring, err := json.Marshal(upgradeList)
if err != nil {
logrus.Fatal(err)
}
fmt.Println(string(jsonstring))
return nil
}
for _, upgrade := range upgradeList {
logrus.Infof("can upgrade service: %s, image: %s, tag: %s ::\n", upgrade.Service, upgrade.Image, upgrade.Tag)
for _, utag := range upgrade.UpgradeTags {
logrus.Infof(" %s\n", utag)
}
}
}
return nil return nil
}, },
} }

View File

@ -1,84 +1,44 @@
package recipe package recipe
import ( import (
"fmt" "coopcloud.tech/abra/cli/formatter"
"sort"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/formatter"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"github.com/olekukonko/tablewriter"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
func sortServiceByName(versions [][]string) func(i, j int) bool { var recipeVersionCommand = &cli.Command{
return func(i, j int) bool {
// NOTE(d1): corresponds to the `tableCol` definition below
if versions[i][1] == "app" {
return true
}
return versions[i][1] < versions[j][1]
}
}
var recipeVersionCommand = cli.Command{
Name: "versions", Name: "versions",
Aliases: []string{"v"},
Usage: "List recipe versions", Usage: "List recipe versions",
Aliases: []string{"v"},
ArgsUsage: "<recipe>", ArgsUsage: "<recipe>",
Description: "Versions are read from the recipe catalogue.",
Flags: []cli.Flag{
internal.DebugFlag,
internal.OfflineFlag,
internal.NoInputFlag,
internal.MachineReadableFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.RecipeNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipe(c) recipe := internal.ValidateRecipe(c)
catl, err := recipePkg.ReadRecipeCatalogue(internal.Offline) catalogue, err := catalogue.ReadRecipeCatalogue()
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
recipeMeta, ok := catl[recipe.Name] recipeMeta, ok := catalogue[recipe.Name]
if !ok { if !ok {
logrus.Fatalf("%s is not published on the catalogue?", recipe.Name) logrus.Fatalf("'%s' recipe doesn't exist?", recipe.Name)
} }
if len(recipeMeta.Versions) == 0 { tableCol := []string{"Version", "Service", "Image", "Tag", "Digest"}
logrus.Fatalf("%s has no catalogue published versions?", recipe.Name) table := formatter.CreateTable(tableCol)
}
for i := len(recipeMeta.Versions) - 1; i >= 0; i-- { for _, serviceVersion := range recipeMeta.Versions {
tableCols := []string{"version", "service", "image", "tag"} for tag, meta := range serviceVersion {
table := formatter.CreateTable(tableCols)
for version, meta := range recipeMeta.Versions[i] {
var versions [][]string
for service, serviceMeta := range meta { for service, serviceMeta := range meta {
versions = append(versions, []string{version, service, serviceMeta.Image, serviceMeta.Tag}) table.Append([]string{tag, service, serviceMeta.Image, serviceMeta.Tag, serviceMeta.Digest})
}
}
} }
sort.Slice(versions, sortServiceByName(versions)) table.SetAutoMergeCells(true)
for _, version := range versions {
table.Append(version)
}
if internal.MachineReadable {
table.JSONRender()
} else {
table.SetAutoMergeCellsByColumnIndex([]int{0})
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.Render() table.Render()
fmt.Println()
}
}
}
return nil return nil
}, },

79
cli/record/list.go Normal file
View File

@ -0,0 +1,79 @@
package record
import (
"fmt"
"strconv"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
gandiPkg "coopcloud.tech/abra/pkg/dns/gandi"
"github.com/libdns/gandi"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
// RecordListCommand lists domains.
var RecordListCommand = &cli.Command{
Name: "list",
Usage: "List domain name records",
Aliases: []string{"ls"},
ArgsUsage: "<zone>",
Flags: []cli.Flag{
internal.DNSProviderFlag,
},
Description: `
This command lists all domain name records managed by a 3rd party provider for
a specific zone.
You must specify a zone (e.g. example.com) under which your domain name records
are listed. This zone must already be created on your provider account.
`,
Action: func(c *cli.Context) error {
if err := internal.EnsureDNSProvider(); err != nil {
logrus.Fatal(err)
}
zone, err := internal.EnsureZoneArgument(c)
if err != nil {
logrus.Fatal(err)
}
var provider gandi.Provider
switch internal.DNSProvider {
case "gandi":
provider, err = gandiPkg.New()
if err != nil {
logrus.Fatal(err)
}
default:
logrus.Fatalf("'%s' is not a supported DNS provider", internal.DNSProvider)
}
records, err := provider.GetRecords(c.Context, zone)
if err != nil {
logrus.Fatal(err)
}
tableCol := []string{"type", "name", "value", "TTL", "priority"}
table := abraFormatter.CreateTable(tableCol)
for _, record := range records {
value := record.Value
if len(record.Value) > 30 {
value = fmt.Sprintf("%s...", record.Value[:30])
}
table.Append([]string{
record.Type,
record.Name,
value,
record.TTL.String(),
strconv.Itoa(record.Priority),
})
}
table.Render()
return nil
},
}

136
cli/record/new.go Normal file
View File

@ -0,0 +1,136 @@
package record
import (
"fmt"
"strconv"
"time"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
gandiPkg "coopcloud.tech/abra/pkg/dns/gandi"
"github.com/libdns/gandi"
"github.com/libdns/libdns"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
// RecordNewCommand creates a new domain name record.
var RecordNewCommand = &cli.Command{
Name: "new",
Usage: "Create a new domain record",
Aliases: []string{"n"},
ArgsUsage: "<zone>",
Flags: []cli.Flag{
internal.DNSProviderFlag,
internal.DNSTypeFlag,
internal.DNSNameFlag,
internal.DNSValueFlag,
internal.DNSTTLFlag,
internal.DNSPriorityFlag,
},
Description: `
This command creates a new domain name record for a specific zone.
You must specify a zone (e.g. example.com) under which your domain name records
are listed. This zone must already be created on your provider account.
Example:
abra record new foo.com -p gandi -t A -n myapp -v 192.168.178.44
You may also invoke this command in "wizard" mode and be prompted for input
abra record new
`,
Action: func(c *cli.Context) error {
zone, err := internal.EnsureZoneArgument(c)
if err != nil {
logrus.Fatal(err)
}
if err := internal.EnsureDNSProvider(); err != nil {
logrus.Fatal(err)
}
var provider gandi.Provider
switch internal.DNSProvider {
case "gandi":
provider, err = gandiPkg.New()
if err != nil {
logrus.Fatal(err)
}
default:
logrus.Fatalf("'%s' is not a supported DNS provider", internal.DNSProvider)
}
if err := internal.EnsureDNSTypeFlag(c); err != nil {
logrus.Fatal(err)
}
if err := internal.EnsureDNSNameFlag(c); err != nil {
logrus.Fatal(err)
}
if err := internal.EnsureDNSValueFlag(c); err != nil {
logrus.Fatal(err)
}
record := libdns.Record{
Type: internal.DNSType,
Name: internal.DNSName,
Value: internal.DNSValue,
TTL: time.Duration(internal.DNSTTL),
}
if internal.DNSType == "MX" || internal.DNSType == "SRV" || internal.DNSType == "URI" {
record.Priority = internal.DNSPriority
}
records, err := provider.GetRecords(c.Context, zone)
if err != nil {
logrus.Fatal(err)
}
for _, existingRecord := range records {
if existingRecord.Type == record.Type &&
existingRecord.Name == record.Name &&
existingRecord.Value == record.Value {
logrus.Fatal("provider library reports that this record already exists?")
}
}
createdRecords, err := provider.SetRecords(
c.Context,
zone,
[]libdns.Record{record},
)
if len(createdRecords) == 0 {
logrus.Fatal("provider library reports that no record was created?")
}
createdRecord := createdRecords[0]
tableCol := []string{"type", "name", "value", "TTL", "priority"}
table := abraFormatter.CreateTable(tableCol)
value := createdRecord.Value
if len(createdRecord.Value) > 30 {
value = fmt.Sprintf("%s...", createdRecord.Value[:30])
}
table.Append([]string{
createdRecord.Type,
createdRecord.Name,
value,
createdRecord.TTL.String(),
strconv.Itoa(createdRecord.Priority),
})
table.Render()
logrus.Info("record created")
return nil
},
}

38
cli/record/record.go Normal file
View File

@ -0,0 +1,38 @@
package record
import (
"github.com/urfave/cli/v2"
)
// RecordCommand supports managing DNS entries.
var RecordCommand = &cli.Command{
Name: "record",
Usage: "Manage domain name records via 3rd party providers",
Aliases: []string{"rc"},
ArgsUsage: "<record>",
Description: `
This command supports managing domain name records via 3rd party providers such
as Gandi DNS. It supports listing, creating and removing all types of records
that you might need for managing Co-op Cloud apps.
The following providers are supported:
Gandi DNS https://www.gandi.net
You need an account with such a provider already. Typically, you need to
provide an API token on the Abra command-line when using these commands so that
you can authenticate with your provider account.
New providers can be integrated, we welcome change sets. See the underlying DNS
library documentation for more. It supports many existing providers and allows
to implement new provider support easily.
https://pkg.go.dev/github.com/libdns/libdns
`,
Subcommands: []*cli.Command{
RecordListCommand,
RecordNewCommand,
RecordRemoveCommand,
},
}

130
cli/record/remove.go Normal file
View File

@ -0,0 +1,130 @@
package record
import (
"fmt"
"strconv"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
gandiPkg "coopcloud.tech/abra/pkg/dns/gandi"
"github.com/AlecAivazis/survey/v2"
"github.com/libdns/gandi"
"github.com/libdns/libdns"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
// RecordRemoveCommand lists domains.
var RecordRemoveCommand = &cli.Command{
Name: "remove",
Usage: "Remove a domain name record",
Aliases: []string{"rm"},
ArgsUsage: "<zone>",
Flags: []cli.Flag{
internal.DNSProviderFlag,
internal.DNSTypeFlag,
internal.DNSNameFlag,
},
Description: `
This command removes a domain name record for a specific zone.
It uses the type of record and name to match existing records and choose one
for deletion. You must specify a zone (e.g. example.com) under which your
domain name records are listed. This zone must already be created on your
provider account.
Example:
abra record remove foo.com -p gandi -t A -n myapp
You may also invoke this command in "wizard" mode and be prompted for input
abra record rm
`,
Action: func(c *cli.Context) error {
zone, err := internal.EnsureZoneArgument(c)
if err != nil {
logrus.Fatal(err)
}
if err := internal.EnsureDNSProvider(); err != nil {
logrus.Fatal(err)
}
var provider gandi.Provider
switch internal.DNSProvider {
case "gandi":
provider, err = gandiPkg.New()
if err != nil {
logrus.Fatal(err)
}
default:
logrus.Fatalf("'%s' is not a supported DNS provider", internal.DNSProvider)
}
if err := internal.EnsureDNSTypeFlag(c); err != nil {
logrus.Fatal(err)
}
if err := internal.EnsureDNSNameFlag(c); err != nil {
logrus.Fatal(err)
}
records, err := provider.GetRecords(c.Context, zone)
if err != nil {
logrus.Fatal(err)
}
var toDelete libdns.Record
for _, record := range records {
if record.Type == internal.DNSType && record.Name == internal.DNSName {
toDelete = record
break
}
}
if (libdns.Record{}) == toDelete {
logrus.Fatal("provider library reports no matching record?")
}
tableCol := []string{"type", "name", "value", "TTL", "priority"}
table := abraFormatter.CreateTable(tableCol)
value := toDelete.Value
if len(toDelete.Value) > 30 {
value = fmt.Sprintf("%s...", toDelete.Value[:30])
}
table.Append([]string{
toDelete.Type,
toDelete.Name,
value,
toDelete.TTL.String(),
strconv.Itoa(toDelete.Priority),
})
table.Render()
response := false
prompt := &survey.Confirm{
Message: "continue with record deletion?",
}
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
if !response {
logrus.Fatal("exiting as requested")
}
_, err = provider.DeleteRecords(c.Context, zone, []libdns.Record{toDelete})
if err != nil {
logrus.Fatal(err)
}
logrus.Info("record successfully deleted")
return nil
},
}

View File

@ -1,58 +1,170 @@
package server package server
import ( import (
"context"
"errors" "errors"
"fmt"
"net"
"os" "os"
"os/exec"
"os/user"
"path"
"path/filepath" "path/filepath"
"strings"
"time"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
contextPkg "coopcloud.tech/abra/pkg/context" contextPkg "coopcloud.tech/abra/pkg/context"
"coopcloud.tech/abra/pkg/dns"
"coopcloud.tech/abra/pkg/server" "coopcloud.tech/abra/pkg/server"
sshPkg "coopcloud.tech/abra/pkg/ssh" "coopcloud.tech/abra/pkg/ssh"
"github.com/AlecAivazis/survey/v2"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/swarm"
dockerClient "github.com/docker/docker/client"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
)
var (
dockerInstallMsg = `
A docker installation cannot be found on %s. This is a required system
dependency for running Co-op Cloud on your server. If you would like, Abra can
attempt to install Docker for you using the upstream non-interactive
installation script.
See the following documentation for more:
https://docs.docker.com/engine/install/debian/#install-using-the-convenience-script
N.B Docker doesn't recommend it for production environments but many use it for
such purposes. Docker stable is now installed by default by this script. The
source for this script can be seen here:
https://github.com/docker/docker-install
`
) )
var local bool var local bool
var localFlag = &cli.BoolFlag{ var localFlag = &cli.BoolFlag{
Name: "local, l", Name: "local",
Aliases: []string{"l"},
Value: false,
Usage: "Use local server", Usage: "Use local server",
Destination: &local, Destination: &local,
} }
var provision bool
var provisionFlag = &cli.BoolFlag{
Name: "provision",
Aliases: []string{"p"},
Value: false,
Usage: "Provision server so it can deploy apps",
Destination: &provision,
}
var sshAuth string
var sshAuthFlag = &cli.StringFlag{
Name: "ssh-auth",
Aliases: []string{"sh"},
Value: "identity-file",
Usage: "Select SSH authentication method (identity-file, password)",
Destination: &sshAuth,
}
var askSudoPass bool
var askSudoPassFlag = &cli.BoolFlag{
Name: "ask-sudo-pass",
Aliases: []string{"as"},
Value: false,
Usage: "Ask for sudo password",
Destination: &askSudoPass,
}
var traefik bool
var traefikFlag = &cli.BoolFlag{
Name: "traefik",
Aliases: []string{"t"},
Value: false,
Usage: "Deploy traefik",
Destination: &traefik,
}
func cleanUp(domainName string) { func cleanUp(domainName string) {
if domainName != "default" { logrus.Warnf("cleaning up context for %s", domainName)
logrus.Infof("cleaning up context for %s", domainName)
if err := client.DeleteContext(domainName); err != nil { if err := client.DeleteContext(domainName); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
}
logrus.Infof("attempting to clean up server directory for %s", domainName) logrus.Warnf("cleaning up server directory for %s", domainName)
if err := os.RemoveAll(filepath.Join(config.ABRA_SERVER_FOLDER, domainName)); err != nil {
serverDir := filepath.Join(config.SERVERS_DIR, domainName)
files, err := config.GetAllFilesInDirectory(serverDir)
if err != nil {
logrus.Fatalf("unable to list files in %s: %s", serverDir, err)
}
if len(files) > 0 {
logrus.Warnf("aborting clean up of %s because it is not empty", serverDir)
return
}
if err := os.RemoveAll(serverDir); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} }
// newContext creates a new internal Docker context for a server. This is how func installDockerLocal(c *cli.Context) error {
// Docker manages SSH connection details. These are stored to disk in fmt.Println(fmt.Sprintf(dockerInstallMsg, "this local server"))
// ~/.docker. Abra can manage this completely for the user, so it's an
// implementation detail. response := false
prompt := &survey.Confirm{
Message: fmt.Sprintf("attempt install docker on local server?"),
}
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
if !response {
logrus.Fatal("exiting as requested")
}
cmd := exec.Command("bash", "-c", "curl -s https://get.docker.com | bash")
if err := internal.RunCmd(cmd); err != nil {
return err
}
return nil
}
func newLocalServer(c *cli.Context, domainName string) error {
if err := createServerDir(domainName); err != nil {
return err
}
cl, err := newClient(c, domainName)
if err != nil {
return err
}
if provision {
out, err := exec.Command("which", "docker").Output()
if err != nil {
return err
}
if string(out) == "" {
if err := installDockerLocal(c); err != nil {
return err
}
}
if err := initSwarmLocal(c, cl, domainName); err != nil {
if !strings.Contains(err.Error(), "proxy already exists") {
logrus.Fatal(err)
}
}
}
if traefik {
if err := deployTraefik(c, cl, domainName); err != nil {
return err
}
}
logrus.Info("local server has been added")
return nil
}
func newContext(c *cli.Context, domainName, username, port string) error { func newContext(c *cli.Context, domainName, username, port string) error {
store := contextPkg.NewDefaultDockerContextStore() store := contextPkg.NewDefaultDockerContextStore()
contexts, err := store.Store.List() contexts, err := store.Store.List()
@ -76,7 +188,139 @@ func newContext(c *cli.Context, domainName, username, port string) error {
return nil return nil
} }
// createServerDir creates the ~/.abra/servers/... directory for a new server. func newClient(c *cli.Context, domainName string) (*dockerClient.Client, error) {
cl, err := client.New(domainName)
if err != nil {
return &dockerClient.Client{}, err
}
return cl, nil
}
func installDocker(c *cli.Context, cl *dockerClient.Client, sshCl *ssh.Client, domainName string) error {
result, err := sshCl.Exec("which docker")
if err != nil && string(result) != "" {
return err
}
if string(result) == "" {
fmt.Println(fmt.Sprintf(dockerInstallMsg, domainName))
response := false
prompt := &survey.Confirm{
Message: fmt.Sprintf("attempt install docker on %s?", domainName),
}
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
if !response {
logrus.Fatal("exiting as requested")
}
cmd := "curl -s https://get.docker.com | bash"
var sudoPass string
if askSudoPass {
prompt := &survey.Password{
Message: "sudo password?",
}
if err := survey.AskOne(prompt, &sudoPass); err != nil {
return err
}
logrus.Debugf("running '%s' on %s now with sudo password", cmd, domainName)
if err := ssh.RunSudoCmd(cmd, sudoPass, sshCl); err != nil {
return err
}
} else {
logrus.Debugf("running '%s' on %s now without sudo password", cmd, domainName)
if err := ssh.Exec(cmd, sshCl); err != nil {
return err
}
}
}
logrus.Infof("docker is installed on %s", domainName)
return nil
}
func initSwarmLocal(c *cli.Context, cl *dockerClient.Client, domainName string) error {
initReq := swarm.InitRequest{ListenAddr: "0.0.0.0:2377"}
if _, err := cl.SwarmInit(c.Context, initReq); err != nil {
if !strings.Contains(err.Error(), "is already part of a swarm") {
return err
}
logrus.Info("swarm mode already initialised on local server")
} else {
logrus.Infof("initialised swarm mode on local server")
}
netOpts := types.NetworkCreate{Driver: "overlay", Scope: "swarm"}
if _, err := cl.NetworkCreate(c.Context, "proxy", netOpts); err != nil {
if !strings.Contains(err.Error(), "proxy already exists") {
return err
}
logrus.Info("swarm overlay network already created on local server")
} else {
logrus.Infof("swarm overlay network created on local server")
}
return nil
}
func initSwarm(c *cli.Context, cl *dockerClient.Client, domainName string) error {
// comrade librehosters DNS resolver -> https://www.privacy-handbuch.de/handbuch_93d.htm
freifunkDNS := "5.1.66.255:53"
resolver := &net.Resolver{
PreferGo: false,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{
Timeout: time.Millisecond * time.Duration(10000),
}
return d.DialContext(ctx, "udp", freifunkDNS)
},
}
logrus.Debugf("created DNS resolver via '%s'", freifunkDNS)
ips, err := resolver.LookupIPAddr(c.Context, domainName)
if err != nil {
return err
}
if len(ips) == 0 {
return fmt.Errorf("unable to retrieve ipv4 address for %s", domainName)
}
ipv4 := ips[0].IP.To4().String()
logrus.Debugf("discovered the following ipv4 addr: %s", ipv4)
initReq := swarm.InitRequest{
ListenAddr: "0.0.0.0:2377",
AdvertiseAddr: ipv4,
}
if _, err := cl.SwarmInit(c.Context, initReq); err != nil {
if !strings.Contains(err.Error(), "is already part of a swarm") {
return err
}
logrus.Infof("swarm mode already initialised on %s", domainName)
} else {
logrus.Infof("initialised swarm mode on %s", domainName)
}
netOpts := types.NetworkCreate{Driver: "overlay", Scope: "swarm"}
if _, err := cl.NetworkCreate(c.Context, "proxy", netOpts); err != nil {
if !strings.Contains(err.Error(), "proxy already exists") {
return err
}
logrus.Infof("swarm overlay network already created on %s", domainName)
} else {
logrus.Infof("swarm overlay network created on %s", domainName)
}
return nil
}
func createServerDir(domainName string) error { func createServerDir(domainName string) error {
if err := server.CreateServerDir(domainName); err != nil { if err := server.CreateServerDir(domainName); err != nil {
if !os.IsExist(err) { if !os.IsExist(err) {
@ -84,97 +328,182 @@ func createServerDir(domainName string) error {
} }
logrus.Debugf("server dir for %s already created", domainName) logrus.Debugf("server dir for %s already created", domainName)
} }
return nil
}
func deployTraefik(c *cli.Context, cl *dockerClient.Client, domainName string) error {
internal.NoInput = true
internal.RecipeName = "traefik"
internal.NewAppServer = domainName
internal.Domain = fmt.Sprintf("%s.%s", "traefik", domainName)
internal.NewAppName = fmt.Sprintf("%s_%s", "traefik", config.SanitiseAppName(domainName))
appEnvPath := path.Join(config.ABRA_DIR, "servers", internal.Domain, fmt.Sprintf("%s.env", internal.NewAppName))
if _, err := os.Stat(appEnvPath); !os.IsNotExist(err) {
fmt.Println(fmt.Sprintf(`
You specified "--traefik/-t" and that means that Abra will now try to
automatically create a new Traefik app on %s.
`, internal.NewAppServer))
tableCol := []string{"recipe", "domain", "server", "name"}
table := abraFormatter.CreateTable(tableCol)
table.Append([]string{internal.RecipeName, internal.Domain, internal.NewAppServer, internal.NewAppName})
if err := internal.NewAction(c); err != nil {
logrus.Fatal(err)
}
} else {
logrus.Infof("%s already exists, not creating again", appEnvPath)
}
internal.AppName = internal.NewAppName
if err := internal.DeployAction(c); err != nil {
logrus.Fatal(err)
}
return nil return nil
} }
var serverAddCommand = cli.Command{ var serverAddCommand = &cli.Command{
Name: "add", Name: "add",
Aliases: []string{"a"},
Usage: "Add a server to your configuration", Usage: "Add a server to your configuration",
Description: ` Description: `
Add a new server to your configuration so that it can be managed by Abra. This command adds a new server to your configuration so that it can be managed
by Abra. This can be useful when you already have a server provisioned and want
to start running Abra commands against it.
Abra uses the SSH command-line to discover connection details for your server. This command can also provision your server ("--provision/-p") so that it is
It is advised to configure an entry per-host in your ~/.ssh/config for each capable of hosting Co-op Cloud apps. Abra will default to expecting that you
server. For example: have a running ssh-agent and are using SSH keys to connect to your new server.
Abra will also read your SSH config (matching "Host" as <domain>). SSH
connection details precedence follows as such: command-line > SSH config >
guessed defaults.
Host example.com If you have no SSH key configured for this host and are instead using password
Hostname example.com authentication, you may pass "--ssh-auth password" to have Abra ask you for the
User exampleUser password. "--ask-sudo-pass" may be passed if you run your provisioning commands
Port 12345 via sudo privilege escalation.
IdentityFile ~/.ssh/example@somewhere
Abra can then load SSH connection details from this configuratiion with:
abra server add example.com
If "--local" is passed, then Abra assumes that the current local server is If "--local" is passed, then Abra assumes that the current local server is
intended as the target server. This is useful when you want to have your entire intended as the target server. This is useful when you want to have your entire
Co-op Cloud config located on the server itself, and not on your local Co-op Cloud config located on the server itself, and not on your local
developer machine. developer machine.
Example:
abra server add --local
Otherwise, you may specify a remote server. The <domain> argument must be a
publicy accessible domain name which points to your server. You should have SSH
access to this server, Abra will assume port 22 and will use your current
system username to make an initial connection. You can use the <user> and
<port> arguments to adjust this.
Example:
abra server add --provision --traefik varia.zone glodemodem 12345
Abra will construct the following SSH connection and Docker context:
ssh://globemodem@varia.zone:12345
All communication between Abra and the server will use this SSH connection.
In this example, Abra will run the following operations:
1. Install Docker
2. Initialise Swarm mode
3. Deploy Traefik (core web proxy)
You may omit flags to avoid performing this provisioning logic.
`, `,
Aliases: []string{"a"},
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
localFlag, localFlag,
provisionFlag,
sshAuthFlag,
askSudoPassFlag,
traefikFlag,
}, },
Before: internal.SubCommandBefore, ArgsUsage: "<domain> [<user>] [<port>]",
ArgsUsage: "<domain>",
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
if len(c.Args()) > 0 && local || !internal.ValidateSubCmdFlags(c) { if c.Args().Len() > 0 && local || !internal.ValidateSubCmdFlags(c) {
err := errors.New("cannot use <domain> and --local together") err := errors.New("cannot use '<domain>' and '--local' together")
internal.ShowSubcommandHelpAndError(c, err) internal.ShowSubcommandHelpAndError(c, err)
} }
var domainName string if sshAuth != "password" && sshAuth != "identity-file" {
if local { err := errors.New("--ssh-auth only accepts 'identity-file' or 'password'")
domainName = "default" internal.ShowSubcommandHelpAndError(c, err)
} else {
domainName = internal.ValidateDomain(c)
} }
if local { if local {
if err := createServerDir(domainName); err != nil { if err := newLocalServer(c, "default"); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Infof("attempting to create client for %s", domainName)
if _, err := client.New(domainName); err != nil {
cleanUp(domainName)
logrus.Fatal(err)
}
logrus.Info("local server added")
return nil return nil
} }
if _, err := dns.EnsureIPv4(domainName); err != nil { domainName, err := internal.ValidateDomain(c)
logrus.Fatal(err)
}
if err := createServerDir(domainName); err != nil {
logrus.Fatal(err)
}
hostConfig, err := sshPkg.GetHostConfig(domainName)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if err := newContext(c, domainName, hostConfig.User, hostConfig.Port); err != nil { username := c.Args().Get(1)
if username == "" {
systemUser, err := user.Current()
if err != nil {
return err
}
username = systemUser.Username
}
port := c.Args().Get(2)
if port == "" {
port = "22"
}
if err := createServerDir(domainName); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Infof("attempting to create client for %s", domainName) if err := newContext(c, domainName, username, port); err != nil {
if _, err := client.New(domainName); err != nil { logrus.Fatal(err)
cleanUp(domainName)
logrus.Debugf("failed to construct client for %s, saw %s", domainName, err.Error())
logrus.Fatal(sshPkg.Fatal(domainName, err))
} }
logrus.Infof("%s added", domainName) cl, err := newClient(c, domainName)
if err != nil {
logrus.Fatal(err)
}
if provision {
logrus.Debugf("attempting to construct SSH client for %s", domainName)
sshCl, err := ssh.New(domainName, sshAuth, username, port)
if err != nil {
logrus.Fatal(err)
}
defer sshCl.Close()
logrus.Debugf("successfully created SSH client for %s", domainName)
if err := installDocker(c, cl, sshCl, domainName); err != nil {
logrus.Fatal(err)
}
if err := initSwarm(c, cl, domainName); err != nil {
logrus.Fatal(err)
}
}
if _, err := cl.Info(c.Context); err != nil {
cleanUp(domainName)
logrus.Fatalf("couldn't make a remote docker connection to %s? use --provision/-p to attempt to install", domainName)
}
if traefik {
if err := deployTraefik(c, cl, domainName); err != nil {
logrus.Fatal(err)
}
}
return nil return nil
}, },

View File

@ -3,34 +3,20 @@ package server
import ( import (
"strings" "strings"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/context" "coopcloud.tech/abra/pkg/context"
"coopcloud.tech/abra/pkg/formatter"
"github.com/docker/cli/cli/connhelper/ssh" "github.com/docker/cli/cli/connhelper/ssh"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var problemsFilter bool var serverListCommand = &cli.Command{
var problemsFilterFlag = &cli.BoolFlag{
Name: "problems, p",
Usage: "Show only servers with potential connection problems",
Destination: &problemsFilter,
}
var serverListCommand = cli.Command{
Name: "list", Name: "list",
Aliases: []string{"ls"}, Aliases: []string{"ls"},
Usage: "List managed servers", Usage: "List managed servers",
Flags: []cli.Flag{ ArgsUsage: " ",
problemsFilterFlag, HideHelp: true,
internal.DebugFlag,
internal.MachineReadableFlag,
internal.OfflineFlag,
},
Before: internal.SubCommandBefore,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
dockerContextStore := context.NewDefaultDockerContextStore() dockerContextStore := context.NewDefaultDockerContextStore()
contexts, err := dockerContextStore.Store.List() contexts, err := dockerContextStore.Store.List()
@ -40,6 +26,7 @@ var serverListCommand = cli.Command{
tableColumns := []string{"name", "host", "user", "port"} tableColumns := []string{"name", "host", "user", "port"}
table := formatter.CreateTable(tableColumns) table := formatter.CreateTable(tableColumns)
defer table.Render()
serverNames, err := config.ReadServerNames() serverNames, err := config.ReadServerNames()
if err != nil { if err != nil {
@ -54,27 +41,14 @@ var serverListCommand = cli.Command{
// No local context found, we can continue safely // No local context found, we can continue safely
continue continue
} }
if ctx.Name == serverName { if ctx.Name == serverName {
sp, err := ssh.ParseURL(endpoint) sp, err := ssh.ParseURL(endpoint)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if sp.Host == "" {
sp.Host = "unknown"
}
if sp.User == "" {
sp.User = "unknown"
}
if sp.Port == "" {
sp.Port = "unknown"
}
row = []string{serverName, sp.Host, sp.User, sp.Port} row = []string{serverName, sp.Host, sp.User, sp.Port}
} }
} }
if len(row) == 0 { if len(row) == 0 {
if serverName == "default" { if serverName == "default" {
row = []string{serverName, "local", "n/a", "n/a"} row = []string{serverName, "local", "n/a", "n/a"}
@ -82,27 +56,7 @@ var serverListCommand = cli.Command{
row = []string{serverName, "unknown", "unknown", "unknown"} row = []string{serverName, "unknown", "unknown", "unknown"}
} }
} }
if problemsFilter {
for _, val := range row {
if val == "unknown" {
table.Append(row) table.Append(row)
break
}
}
} else {
table.Append(row)
}
}
if internal.MachineReadable {
table.JSONRender()
} else {
if problemsFilter && table.NumLines() == 0 {
logrus.Info("all servers wired up correctly 👏")
} else {
table.Render()
}
} }
return nil return nil

236
cli/server/new.go Normal file
View File

@ -0,0 +1,236 @@
package server
import (
"fmt"
"strings"
"coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/libcapsul"
"github.com/AlecAivazis/survey/v2"
"github.com/hetznercloud/hcloud-go/hcloud"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
func newHetznerCloudVPS(c *cli.Context) error {
if err := internal.EnsureNewHetznerCloudVPSFlags(c); err != nil {
return err
}
client := hcloud.NewClient(hcloud.WithToken(internal.HetznerCloudAPIToken))
var sshKeysRaw []string
var sshKeys []*hcloud.SSHKey
for _, sshKey := range c.StringSlice("hetzner-ssh-keys") {
if sshKey == "" {
continue
}
sshKey, _, err := client.SSHKey.GetByName(c.Context, sshKey)
if err != nil {
return err
}
sshKeys = append(sshKeys, sshKey)
sshKeysRaw = append(sshKeysRaw, sshKey.Name)
}
serverOpts := hcloud.ServerCreateOpts{
Name: internal.HetznerCloudName,
ServerType: &hcloud.ServerType{Name: internal.HetznerCloudType},
Image: &hcloud.Image{Name: internal.HetznerCloudImage},
SSHKeys: sshKeys,
Location: &hcloud.Location{Name: internal.HetznerCloudLocation},
}
tableColumns := []string{"name", "type", "image", "ssh-keys", "location"}
table := formatter.CreateTable(tableColumns)
table.Append([]string{
internal.HetznerCloudName,
internal.HetznerCloudType,
internal.HetznerCloudImage,
strings.Join(sshKeysRaw, "\n"),
internal.HetznerCloudLocation,
})
table.Render()
response := false
prompt := &survey.Confirm{
Message: "continue with hetzner cloud VPS creation?",
}
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
if !response {
logrus.Fatal("exiting as requested")
}
res, _, err := client.Server.Create(c.Context, serverOpts)
if err != nil {
return err
}
var rootPassword string
if len(sshKeys) > 0 {
rootPassword = "N/A (using SSH keys)"
} else {
rootPassword = res.RootPassword
}
ip := res.Server.PublicNet.IPv4.IP.String()
fmt.Println(fmt.Sprintf(`
Your new Hetzner Cloud VPS has successfully been created! Here are the details:
name: %s
IP address: %s
root password: %s
You can access this new VPS via SSH using the following command:
ssh root@%s
Please note, this server is not managed by Abra yet (i.e. "abra server ls" will
not list this server)! You will need to assign a domain name record ("abra
record new") and add the server to your Abra configuration ("abra server add")
to have a working server that you can deploy Co-op Cloud apps to.
`,
internal.HetznerCloudName, ip, rootPassword,
ip,
))
return nil
}
func newCapsulVPS(c *cli.Context) error {
if err := internal.EnsureNewCapsulVPSFlags(c); err != nil {
return err
}
capsulCreateURL := fmt.Sprintf("https://%s/api/capsul/create", internal.CapsulInstanceURL)
var sshKeys []string
for _, sshKey := range c.StringSlice("capsul-ssh-keys") {
if sshKey == "" {
continue
}
sshKeys = append(sshKeys, sshKey)
}
tableColumns := []string{"instance", "name", "type", "image", "ssh-keys"}
table := formatter.CreateTable(tableColumns)
table.Append([]string{
internal.CapsulInstanceURL,
internal.CapsulName,
internal.CapsulType,
internal.CapsulImage,
strings.Join(sshKeys, "\n"),
})
table.Render()
response := false
prompt := &survey.Confirm{
Message: "continue with capsul creation?",
}
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
if !response {
logrus.Fatal("exiting as requested")
}
capsulClient := libcapsul.New(capsulCreateURL, internal.CapsulAPIToken)
resp, err := capsulClient.Create(
internal.CapsulName,
internal.CapsulType,
internal.CapsulImage,
sshKeys,
)
if err != nil {
return err
}
fmt.Println(fmt.Sprintf(`
Your new Capsul has successfully been created! Here are the details:
Capsul name: %s
Capsul ID: %v
You will need to log into your Capsul instance web interface to retrieve the IP
address. You can learn all about how to get SSH access to your new Capsul on:
%s/about-ssh
Please note, this server is not managed by Abra yet (i.e. "abra server ls" will
not list this server)! You will need to assign a domain name record ("abra
record new") and add the server to your Abra configuration ("abra server add")
to have a working server that you can deploy Co-op Cloud apps to.
`, internal.CapsulName, resp.ID, internal.CapsulInstanceURL))
return nil
}
var serverNewCommand = &cli.Command{
Name: "new",
Aliases: []string{"n"},
Usage: "Create a new server using a 3rd party provider",
Description: `
This command creates a new server via a 3rd party provider.
The following providers are supported:
Capsul https://git.cyberia.club/Cyberia/capsul-flask
Hetzner Cloud https://docs.hetzner.com/cloud
You may invoke this command in "wizard" mode and be prompted for input:
abra record new
API tokens are read from the environment if specified, e.g.
export HCLOUD_TOKEN=...
Where "$provider_TOKEN" is the expected env var format.
`,
ArgsUsage: "<provider>",
Flags: []cli.Flag{
internal.ServerProviderFlag,
// Capsul
internal.CapsulInstanceURLFlag,
internal.CapsulTypeFlag,
internal.CapsulImageFlag,
internal.CapsulSSHKeysFlag,
internal.CapsulAPITokenFlag,
// Hetzner
internal.HetznerCloudNameFlag,
internal.HetznerCloudTypeFlag,
internal.HetznerCloudImageFlag,
internal.HetznerCloudSSHKeysFlag,
internal.HetznerCloudLocationFlag,
internal.HetznerCloudAPITokenFlag,
},
Action: func(c *cli.Context) error {
if err := internal.EnsureServerProvider(); err != nil {
logrus.Fatal(err)
}
switch internal.ServerProvider {
case "capsul":
if err := newCapsulVPS(c); err != nil {
logrus.Fatal(err)
}
case "hetzner-cloud":
if err := newHetznerCloudVPS(c); err != nil {
logrus.Fatal(err)
}
}
return nil
},
}

View File

@ -1,103 +0,0 @@
package server
import (
"context"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/formatter"
"github.com/docker/docker/api/types/filters"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
)
var allFilter bool
var allFilterFlag = &cli.BoolFlag{
Name: "all, a",
Usage: "Remove all unused images not just dangling ones",
Destination: &allFilter,
}
var volumesFilter bool
var volumesFilterFlag = &cli.BoolFlag{
Name: "volumes, v",
Usage: "Prune volumes. This will remove app data, Be Careful!",
Destination: &volumesFilter,
}
var serverPruneCommand = cli.Command{
Name: "prune",
Aliases: []string{"p"},
Usage: "Prune a managed server; Runs a docker system prune",
Description: `
Prunes unused containers, networks, and dangling images.
If passing "-v/--volumes" then volumes not connected to a deployed app will
also be removed. This can result in unwanted data loss if not used carefully.
`,
ArgsUsage: "[<server>]",
Flags: []cli.Flag{
allFilterFlag,
volumesFilterFlag,
internal.DebugFlag,
internal.OfflineFlag,
internal.NoInputFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.ServerNameComplete,
Action: func(c *cli.Context) error {
serverName := internal.ValidateServer(c)
cl, err := client.New(serverName)
if err != nil {
logrus.Fatal(err)
}
var args filters.Args
ctx := context.Background()
cr, err := cl.ContainersPrune(ctx, args)
if err != nil {
logrus.Fatal(err)
}
cntSpaceReclaimed := formatter.ByteCountSI(cr.SpaceReclaimed)
logrus.Infof("containers pruned: %d; space reclaimed: %s", len(cr.ContainersDeleted), cntSpaceReclaimed)
nr, err := cl.NetworksPrune(ctx, args)
if err != nil {
logrus.Fatal(err)
}
logrus.Infof("networks pruned: %d", len(nr.NetworksDeleted))
pruneFilters := filters.NewArgs()
if allFilter {
logrus.Debugf("removing all images, not only dangling ones")
pruneFilters.Add("dangling", "false")
}
ir, err := cl.ImagesPrune(ctx, pruneFilters)
if err != nil {
logrus.Fatal(err)
}
imgSpaceReclaimed := formatter.ByteCountSI(ir.SpaceReclaimed)
logrus.Infof("images pruned: %d; space reclaimed: %s", len(ir.ImagesDeleted), imgSpaceReclaimed)
if volumesFilter {
vr, err := cl.VolumesPrune(ctx, args)
if err != nil {
logrus.Fatal(err)
}
volSpaceReclaimed := formatter.ByteCountSI(vr.SpaceReclaimed)
logrus.Infof("volumes pruned: %d; space reclaimed: %s", len(vr.VolumesDeleted), volSpaceReclaimed)
}
return nil
},
}

View File

@ -1,47 +1,158 @@
package server package server
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"github.com/AlecAivazis/survey/v2"
"github.com/hetznercloud/hcloud-go/hcloud"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var serverRemoveCommand = cli.Command{ var rmServer bool
var rmServerFlag = &cli.BoolFlag{
Name: "server",
Aliases: []string{"s"},
Value: false,
Usage: "remove the actual server also",
Destination: &rmServer,
}
func rmHetznerCloudVPS(c *cli.Context) error {
if internal.HetznerCloudName == "" && !internal.NoInput {
prompt := &survey.Input{
Message: "specify hetzner cloud VPS name",
}
if err := survey.AskOne(prompt, &internal.HetznerCloudName); err != nil {
return err
}
}
if internal.HetznerCloudAPIToken == "" && !internal.NoInput {
token, ok := os.LookupEnv("HCLOUD_TOKEN")
if !ok {
prompt := &survey.Input{
Message: "specify hetzner cloud API token",
}
if err := survey.AskOne(prompt, &internal.HetznerCloudAPIToken); err != nil {
return err
}
} else {
internal.HetznerCloudAPIToken = token
}
}
client := hcloud.NewClient(hcloud.WithToken(internal.HetznerCloudAPIToken))
server, _, err := client.Server.Get(c.Context, internal.HetznerCloudName)
if err != nil {
return err
}
if server == nil {
logrus.Fatalf("library provider reports that %s doesn't exist?", internal.HetznerCloudName)
}
fmt.Println(fmt.Sprintf(`
You have requested that Abra delete the following server (%s). Please be
absolutely sure that this is indeed the server that you would like to have
removed. There will be no going back once you confirm, the server will be
destroyed.
`, server.Name))
tableColumns := []string{"name", "type", "image", "location"}
table := formatter.CreateTable(tableColumns)
table.Append([]string{
server.Name,
server.ServerType.Name,
server.Image.Name,
server.Datacenter.Name,
})
table.Render()
response := false
prompt := &survey.Confirm{
Message: "continue with hetzner cloud VPS removal?",
}
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
if !response {
logrus.Fatal("exiting as requested")
}
_, err = client.Server.Delete(c.Context, server)
if err != nil {
return err
}
logrus.Infof("%s has been deleted from your hetzner cloud account", internal.HetznerCloudName)
return nil
}
var serverRemoveCommand = &cli.Command{
Name: "remove", Name: "remove",
Aliases: []string{"rm"}, Aliases: []string{"rm"},
ArgsUsage: "<server>", ArgsUsage: "<server>",
Usage: "Remove a managed server", Usage: "Remove a managed server",
Description: `Remove a managed server. Description: `
This command removes a server from Abra management.
Abra will remove the internal bookkeeping (~/.abra/servers/...) and underlying Depending on whether you used a 3rd party provider to create this server ("abra
client connection context. This server will then be lost in time, like tears in server new"), you can also destroy the virtual server as well. Pass
rain. "--server/-s" to tell Abra to try to delete this VPS.
Otherwise, Abra will remove the internal bookkeeping (~/.abra/servers/...) and
underlying client connection context. This server will then be lost in time,
like tears in rain.
`, `,
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.DebugFlag, rmServerFlag,
internal.NoInputFlag,
internal.OfflineFlag, // Hetzner
internal.HetznerCloudNameFlag,
internal.HetznerCloudAPITokenFlag,
}, },
Before: internal.SubCommandBefore,
BashComplete: autocomplete.ServerNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
serverName := internal.ValidateServer(c) serverName, err := internal.ValidateServer(c)
if err != nil {
logrus.Fatal(err)
}
if rmServer {
if err := internal.EnsureServerProvider(); err != nil {
logrus.Fatal(err)
}
switch internal.ServerProvider {
case "capsul":
logrus.Warn("capsul provider does not support automatic removal yet, sorry!")
case "hetzner-cloud":
if err := rmHetznerCloudVPS(c); err != nil {
logrus.Fatal(err)
}
}
}
if err := client.DeleteContext(serverName); err != nil { if err := client.DeleteContext(serverName); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if err := os.RemoveAll(filepath.Join(config.SERVERS_DIR, serverName)); err != nil { if err := os.RemoveAll(filepath.Join(config.ABRA_SERVER_FOLDER, serverName)); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Infof("server at %s has been lost in time, like tears in rain", serverName) logrus.Infof("server at '%s' has been lost in time, like tears in rain", serverName)
return nil return nil
}, },

View File

@ -1,18 +1,27 @@
package server package server
import ( import (
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
// ServerCommand defines the `abra server` command and its subcommands // ServerCommand defines the `abra server` command and its subcommands
var ServerCommand = cli.Command{ var ServerCommand = &cli.Command{
Name: "server", Name: "server",
Aliases: []string{"s"}, Aliases: []string{"s"},
Usage: "Manage servers", Usage: "Manage servers via 3rd party providers",
Subcommands: []cli.Command{ Description: `
These commands support creating, managing and removing servers using 3rd party
integrations.
Servers can be created from scratch using the "abra server new" command. If you
already have a server, you can add it to your configuration using "abra server
add". Abra can provision servers so that they are ready to deploy Co-op Cloud
apps, see available flags on "server add" for more.
`,
Subcommands: []*cli.Command{
serverNewCommand,
serverAddCommand, serverAddCommand,
serverListCommand, serverListCommand,
serverRemoveCommand, serverRemoveCommand,
serverPruneCommand,
}, },
} }

View File

@ -1,498 +0,0 @@
package updater
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/lint"
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/upstream/convert"
"coopcloud.tech/abra/pkg/upstream/stack"
"coopcloud.tech/tagcmp"
composetypes "github.com/docker/cli/cli/compose/types"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
dockerclient "github.com/docker/docker/client"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
)
const SERVER = "localhost"
var majorUpdate bool
var majorFlag = &cli.BoolFlag{
Name: "major, m",
Usage: "Also check for major updates",
Destination: &majorUpdate,
}
var updateAll bool
var allFlag = &cli.BoolFlag{
Name: "all, a",
Usage: "Update all deployed apps",
Destination: &updateAll,
}
// Notify checks for available upgrades
var Notify = cli.Command{
Name: "notify",
Aliases: []string{"n"},
Usage: "Check for available upgrades",
Flags: []cli.Flag{
internal.DebugFlag,
majorFlag,
internal.OfflineFlag,
},
Before: internal.SubCommandBefore,
Description: `
It reads the deployed app versions and looks for new versions in the recipe
catalogue. If a new patch/minor version is available, a notification is
printed. To include major versions use the --major flag.
`,
Action: func(c *cli.Context) error {
cl, err := client.New("default")
if err != nil {
logrus.Fatal(err)
}
stacks, err := stack.GetStacks(cl)
if err != nil {
logrus.Fatal(err)
}
for _, stackInfo := range stacks {
stackName := stackInfo.Name
recipeName, err := getLabel(cl, stackName, "recipe")
if err != nil {
logrus.Fatal(err)
}
if recipeName != "" {
_, err = getLatestUpgrade(cl, stackName, recipeName)
if err != nil {
logrus.Fatal(err)
}
}
}
return nil
},
}
// UpgradeApp upgrades apps.
var UpgradeApp = cli.Command{
Name: "upgrade",
Aliases: []string{"u"},
Usage: "Upgrade apps",
ArgsUsage: "<stack-name> <recipe>",
Flags: []cli.Flag{
internal.DebugFlag,
internal.ChaosFlag,
majorFlag,
allFlag,
internal.OfflineFlag,
},
Before: internal.SubCommandBefore,
Description: `
Upgrade an app by specifying its stack name and recipe. By passing "--all"
instead, every deployed app is upgraded. For each apps with enabled auto
updates the deployed version is compared with the current recipe catalogue
version. If a new patch/minor version is available, the app is upgraded. To
include major versions use the "--major" flag. Don't do that, it will probably
break things. Only apps that are not deployed with "--chaos" are upgraded, to
update chaos deployments use the "--chaos" flag. Use it with care.
`,
Action: func(c *cli.Context) error {
cl, err := client.New("default")
if err != nil {
logrus.Fatal(err)
}
if !updateAll {
stackName := c.Args().Get(0)
recipeName := c.Args().Get(1)
err = tryUpgrade(cl, stackName, recipeName)
if err != nil {
logrus.Fatal(err)
}
return nil
}
stacks, err := stack.GetStacks(cl)
if err != nil {
logrus.Fatal(err)
}
for _, stackInfo := range stacks {
stackName := stackInfo.Name
recipeName, err := getLabel(cl, stackName, "recipe")
if err != nil {
logrus.Fatal(err)
}
err = tryUpgrade(cl, stackName, recipeName)
if err != nil {
logrus.Fatal(err)
}
}
return nil
},
}
// getLabel reads docker labels from running services in the format of "coop-cloud.${STACK_NAME}.${LABEL}".
func getLabel(cl *dockerclient.Client, stackName string, label string) (string, error) {
filter := filters.NewArgs()
filter.Add("label", fmt.Sprintf("%s=%s", convert.LabelNamespace, stackName))
services, err := cl.ServiceList(context.Background(), types.ServiceListOptions{Filters: filter})
if err != nil {
return "", err
}
for _, service := range services {
labelKey := fmt.Sprintf("coop-cloud.%s.%s", stackName, label)
if labelValue, ok := service.Spec.Labels[labelKey]; ok {
return labelValue, nil
}
}
logrus.Debugf("no %s label found for %s", label, stackName)
return "", nil
}
// getBoolLabel reads a boolean docker label from running services
func getBoolLabel(cl *dockerclient.Client, stackName string, label string) (bool, error) {
lableValue, err := getLabel(cl, stackName, label)
if err != nil {
return false, err
}
if lableValue != "" {
value, err := strconv.ParseBool(lableValue)
if err != nil {
return false, err
}
return value, nil
}
logrus.Debugf("Boolean label %s could not be found for %s, set default to false.", label, stackName)
return false, nil
}
// getEnv reads env variables from docker services.
func getEnv(cl *dockerclient.Client, stackName string) (config.AppEnv, error) {
envMap := make(map[string]string)
filter := filters.NewArgs()
filter.Add("label", fmt.Sprintf("%s=%s", convert.LabelNamespace, stackName))
services, err := cl.ServiceList(context.Background(), types.ServiceListOptions{Filters: filter})
if err != nil {
return nil, err
}
for _, service := range services {
envList := service.Spec.TaskTemplate.ContainerSpec.Env
for _, envString := range envList {
splitString := strings.SplitN(envString, "=", 2)
if len(splitString) != 2 {
logrus.Debugf("can't separate key from value: %s (this variable is probably unset)", envString)
continue
}
k := splitString[0]
v := splitString[1]
logrus.Debugf("For %s read env %s with value: %s from docker service", stackName, k, v)
envMap[k] = v
}
}
return envMap, nil
}
// getLatestUpgrade returns the latest available version for an app respecting
// the "--major" flag if it is newer than the currently deployed version.
func getLatestUpgrade(cl *dockerclient.Client, stackName string, recipeName string) (string, error) {
deployedVersion, err := getDeployedVersion(cl, stackName, recipeName)
if err != nil {
return "", err
}
availableUpgrades, err := getAvailableUpgrades(cl, stackName, recipeName, deployedVersion)
if err != nil {
return "", err
}
if len(availableUpgrades) == 0 {
logrus.Debugf("no available upgrades for %s", stackName)
return "", nil
}
var chosenUpgrade string
if len(availableUpgrades) > 0 {
chosenUpgrade = availableUpgrades[len(availableUpgrades)-1]
logrus.Infof("%s (%s) can be upgraded from version %s to %s", stackName, recipeName, deployedVersion, chosenUpgrade)
}
return chosenUpgrade, nil
}
// getDeployedVersion returns the currently deployed version of an app.
func getDeployedVersion(cl *dockerclient.Client, stackName string, recipeName string) (string, error) {
logrus.Debugf("Retrieve deployed version whether %s is already deployed", stackName)
isDeployed, deployedVersion, err := stack.IsDeployed(context.Background(), cl, stackName)
if err != nil {
return "", err
}
if !isDeployed {
return "", fmt.Errorf("%s is not deployed?", stackName)
}
if deployedVersion == "unknown" {
return "", fmt.Errorf("failed to determine deployed version of %s", stackName)
}
return deployedVersion, nil
}
// getAvailableUpgrades returns all available versions of an app that are newer
// than the deployed version. It only includes major upgrades if the "--major"
// flag is set.
func getAvailableUpgrades(cl *dockerclient.Client, stackName string, recipeName string,
deployedVersion string) ([]string, error) {
catl, err := recipe.ReadRecipeCatalogue(internal.Offline)
if err != nil {
return nil, err
}
versions, err := recipe.GetRecipeCatalogueVersions(recipeName, catl)
if err != nil {
return nil, err
}
if len(versions) == 0 {
logrus.Warnf("no published releases for %s in the recipe catalogue?", recipeName)
return nil, nil
}
var availableUpgrades []string
for _, version := range versions {
parsedDeployedVersion, err := tagcmp.Parse(deployedVersion)
if err != nil {
return nil, err
}
parsedVersion, err := tagcmp.Parse(version)
if err != nil {
return nil, err
}
versionDelta, err := parsedDeployedVersion.UpgradeDelta(parsedVersion)
if err != nil {
return nil, err
}
if 0 < versionDelta.UpgradeType() && (versionDelta.UpgradeType() < 4 || majorUpdate) {
availableUpgrades = append(availableUpgrades, version)
}
}
logrus.Debugf("Available updates for %s: %s", stackName, availableUpgrades)
return availableUpgrades, nil
}
// processRecipeRepoVersion clones, pulls, checks out the version and lints the
// recipe repository.
func processRecipeRepoVersion(recipeName, version string) error {
if err := recipe.EnsureExists(recipeName); err != nil {
return err
}
if err := recipe.EnsureUpToDate(recipeName); err != nil {
return err
}
if err := recipe.EnsureVersion(recipeName, version); err != nil {
return err
}
if r, err := recipe.Get(recipeName, internal.Offline); err != nil {
return err
} else if err := lint.LintForErrors(r); err != nil {
return err
}
return nil
}
// mergeAbraShEnv merges abra.sh env vars into the app env vars.
func mergeAbraShEnv(recipeName string, env config.AppEnv) error {
abraShPath := fmt.Sprintf("%s/%s/%s", config.RECIPES_DIR, recipeName, "abra.sh")
abraShEnv, err := config.ReadAbraShEnvVars(abraShPath)
if err != nil {
return err
}
for k, v := range abraShEnv {
logrus.Debugf("read v:%s k: %s", v, k)
env[k] = v
}
return nil
}
// createDeployConfig merges and enriches the compose config for the deployment.
func createDeployConfig(recipeName string, stackName string, env config.AppEnv) (*composetypes.Config, stack.Deploy, error) {
env["STACK_NAME"] = stackName
deployOpts := stack.Deploy{
Namespace: stackName,
Prune: false,
ResolveImage: stack.ResolveImageAlways,
}
composeFiles, err := config.GetAppComposeFiles(recipeName, env)
if err != nil {
return nil, deployOpts, err
}
deployOpts.Composefiles = composeFiles
compose, err := config.GetAppComposeConfig(stackName, deployOpts, env)
if err != nil {
return nil, deployOpts, err
}
config.ExposeAllEnv(stackName, compose, env)
// after the upgrade the deployment won't be in chaos state anymore
config.SetChaosLabel(compose, stackName, false)
config.SetRecipeLabel(compose, stackName, recipeName)
config.SetUpdateLabel(compose, stackName, env)
return compose, deployOpts, nil
}
// tryUpgrade performs the upgrade if all the requirements are fulfilled.
func tryUpgrade(cl *dockerclient.Client, stackName, recipeName string) error {
if recipeName == "" {
logrus.Debugf("don't update %s due to missing recipe name", stackName)
return nil
}
chaos, err := getBoolLabel(cl, stackName, "chaos")
if err != nil {
return err
}
if chaos && !internal.Chaos {
logrus.Debugf("don't update %s due to chaos deployment", stackName)
return nil
}
updatesEnabled, err := getBoolLabel(cl, stackName, "autoupdate")
if err != nil {
return err
}
if !updatesEnabled {
logrus.Debugf("don't update %s due to disabled auto updates or missing ENABLE_AUTO_UPDATE env", stackName)
return nil
}
upgradeVersion, err := getLatestUpgrade(cl, stackName, recipeName)
if err != nil {
return err
}
if upgradeVersion == "" {
logrus.Debugf("don't update %s due to no new version", stackName)
return nil
}
err = upgrade(cl, stackName, recipeName, upgradeVersion)
return err
}
// upgrade performs all necessary steps to upgrade an app.
func upgrade(cl *dockerclient.Client, stackName, recipeName,
upgradeVersion string) error {
env, err := getEnv(cl, stackName)
if err != nil {
return err
}
app := config.App{
Name: stackName,
Recipe: recipeName,
Server: SERVER,
Env: env,
}
if err = processRecipeRepoVersion(recipeName, upgradeVersion); err != nil {
return err
}
if err = mergeAbraShEnv(recipeName, app.Env); err != nil {
return err
}
compose, deployOpts, err := createDeployConfig(recipeName, stackName, app.Env)
if err != nil {
return err
}
logrus.Infof("upgrade %s (%s) to version %s", stackName, recipeName, upgradeVersion)
err = stack.RunDeploy(cl, deployOpts, compose, stackName, true)
return err
}
func newAbraApp(version, commit string) *cli.App {
app := &cli.App{
Name: "kadabra",
Usage: `The Co-op Cloud auto-updater
____ ____ _ _
/ ___|___ ___ _ __ / ___| | ___ _ _ __| |
| | / _ \ _____ / _ \| '_ \ | | | |/ _ \| | | |/ _' |
| |__| (_) |_____| (_) | |_) | | |___| | (_) | |_| | (_| |
\____\___/ \___/| .__/ \____|_|\___/ \__,_|\__,_|
|_|
`,
Version: fmt.Sprintf("%s-%s", version, commit[:7]),
Commands: []cli.Command{
Notify,
UpgradeApp,
},
}
app.Before = func(c *cli.Context) error {
logrus.Debugf("kadabra version %s, commit %s", version, commit)
return nil
}
return app
}
// RunApp runs CLI abra app.
func RunApp(version, commit string) {
app := newAbraApp(version, commit)
if err := app.Run(os.Args); err != nil {
logrus.Fatal(err)
}
}

23
cli/upgrade.go Normal file
View File

@ -0,0 +1,23 @@
package cli
import (
"os/exec"
"coopcloud.tech/abra/cli/internal"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
// UpgradeCommand upgrades abra in-place.
var UpgradeCommand = &cli.Command{
Name: "upgrade",
Usage: "Upgrade abra",
Action: func(c *cli.Context) error {
cmd := exec.Command("bash", "-c", "curl -s https://install.abra.coopcloud.tech | bash")
logrus.Debugf("attempting to run '%s'", cmd)
if err := internal.RunCmd(cmd); err != nil {
logrus.Fatal(err)
}
return nil
},
}

View File

@ -5,13 +5,14 @@ import (
"coopcloud.tech/abra/cli" "coopcloud.tech/abra/cli"
) )
// Version is the current version of Abra. // Version is the current version of abra.
var Version string var Version string
// Commit is the current git commit of Abra. // Commit is the current commit of abra.
var Commit string var Commit string
func main() { func main() {
// If not set in the ld-flags
if Version == "" { if Version == "" {
Version = "dev" Version = "dev"
} }

View File

@ -1,23 +0,0 @@
// Package main provides the command-line entrypoint.
package main
import (
"coopcloud.tech/abra/cli/updater"
)
// Version is the current version of Kadabra.
var Version string
// Commit is the current git commit of Kadabra.
var Commit string
func main() {
if Version == "" {
Version = "dev"
}
if Commit == "" {
Commit = " "
}
updater.RunApp(Version, Commit)
}

58
go.mod
View File

@ -4,46 +4,44 @@ go 1.16
require ( require (
coopcloud.tech/tagcmp v0.0.0-20211103052201-885b22f77d52 coopcloud.tech/tagcmp v0.0.0-20211103052201-885b22f77d52
github.com/AlecAivazis/survey/v2 v2.3.7 github.com/AlecAivazis/survey/v2 v2.3.1
github.com/Autonomic-Cooperative/godotenv v1.3.1-0.20210731094149-b031ea1211e7 github.com/Autonomic-Cooperative/godotenv v1.3.1-0.20210731170023-c37c0920d1a4
github.com/Gurpartap/logrus-stack v0.0.0-20170710170904-89c00d8a28f4 github.com/Gurpartap/logrus-stack v0.0.0-20170710170904-89c00d8a28f4
github.com/docker/cli v24.0.6+incompatible github.com/docker/cli v20.10.8+incompatible
github.com/docker/distribution v2.8.2+incompatible github.com/docker/distribution v2.7.1+incompatible
github.com/docker/docker v24.0.6+incompatible github.com/docker/docker v20.10.8+incompatible
github.com/docker/go-units v0.5.0 github.com/docker/go-units v0.4.0
github.com/go-git/go-git/v5 v5.9.0 github.com/go-git/go-git/v5 v5.4.2
github.com/moby/sys/signal v0.7.0 github.com/hetznercloud/hcloud-go v1.32.0
github.com/moby/term v0.5.0 github.com/moby/sys/signal v0.5.0
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6
github.com/olekukonko/tablewriter v0.0.5 github.com/olekukonko/tablewriter v0.0.5
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
github.com/schollz/progressbar/v3 v3.13.1 github.com/schollz/progressbar/v3 v3.8.3
github.com/sirupsen/logrus v1.9.3 github.com/schultz-is/passgen v1.0.1
gotest.tools/v3 v3.5.1 github.com/sirupsen/logrus v1.8.1
github.com/urfave/cli/v2 v2.3.0
gotest.tools/v3 v3.0.3
) )
require ( require (
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230106234847-43070de90fa1 // indirect coopcloud.tech/libcapsul v0.0.0-20211022074848-c35e78fe3f3e
github.com/buger/goterm v1.0.4 github.com/Microsoft/hcsshim v0.8.21 // indirect
github.com/containerd/containerd v1.5.9 // indirect github.com/containerd/containerd v1.5.5 // indirect
github.com/containers/image v3.0.2+incompatible
github.com/containers/storage v1.38.2 // indirect
github.com/decentral1se/passgen v1.0.1
github.com/docker/docker-credential-helpers v0.6.4 // indirect github.com/docker/docker-credential-helpers v0.6.4 // indirect
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect
github.com/fvbommel/sortorder v1.0.2 // indirect github.com/fvbommel/sortorder v1.0.2 // indirect
github.com/gliderlabs/ssh v0.2.2
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/gorilla/mux v1.8.0 // indirect github.com/gorilla/mux v1.8.0 // indirect
github.com/hashicorp/go-retryablehttp v0.7.4 github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351
github.com/klauspost/pgzip v1.2.6 github.com/libdns/gandi v1.0.2
github.com/moby/patternmatcher v0.5.0 // indirect github.com/libdns/libdns v0.2.1
github.com/moby/sys/sequential v0.5.0 // indirect github.com/moby/sys/mount v0.2.0 // indirect
github.com/opencontainers/image-spec v1.0.3-0.20211202193544-a5463b7f9c84 // indirect github.com/morikuni/aec v1.0.0 // indirect
github.com/prometheus/client_golang v1.16.0 // indirect github.com/opencontainers/runc v1.0.2 // indirect
github.com/sergi/go-diff v1.2.0 // indirect
github.com/spf13/cobra v1.3.0 // indirect
github.com/stretchr/testify v1.8.4
github.com/theupdateframework/notary v0.7.0 // indirect github.com/theupdateframework/notary v0.7.0 // indirect
github.com/urfave/cli v1.22.9 github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190809123943-df4f5c81cb3b // indirect golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
golang.org/x/sys v0.12.0 golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0
) )

874
go.sum

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,13 @@
package app package app
import ( import (
"context"
"fmt"
"strings" "strings"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/upstream/stack"
apiclient "github.com/docker/docker/client"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
@ -19,7 +23,7 @@ func Get(appName string) (config.App, error) {
return config.App{}, err return config.App{}, err
} }
logrus.Debugf("retrieved %s for %s", app, appName) logrus.Debugf("retrieved '%s' for '%s'", app, appName)
return app, nil return app, nil
} }
@ -33,10 +37,49 @@ type deployedServiceSpec struct {
// VersionSpec represents a deployed app and associated metadata. // VersionSpec represents a deployed app and associated metadata.
type VersionSpec map[string]deployedServiceSpec type VersionSpec map[string]deployedServiceSpec
// ParseServiceName parses a $STACK_NAME_$SERVICE_NAME service label. // DeployedVersions lists metadata (e.g. versions) for deployed
func DeployedVersions(ctx context.Context, cl *apiclient.Client, app config.App) (VersionSpec, bool, error) {
services, err := stack.GetStackServices(ctx, cl, app.StackName())
if err != nil {
return VersionSpec{}, false, err
}
appSpec := make(VersionSpec)
for _, service := range services {
serviceName := ParseServiceName(service.Spec.Name)
label := fmt.Sprintf("coop-cloud.%s.%s.version", app.StackName(), serviceName)
if deployLabel, ok := service.Spec.Labels[label]; ok {
version, _ := ParseVersionLabel(deployLabel)
appSpec[serviceName] = deployedServiceSpec{Name: serviceName, Version: version}
}
}
deployed := len(services) > 0
if deployed {
logrus.Debugf("detected '%s' as deployed versions of '%s'", appSpec, app.Name)
} else {
logrus.Debugf("detected '%s' as not deployed", app.Name)
}
return appSpec, len(services) > 0, nil
}
// ParseVersionLabel parses a $VERSION-$DIGEST app service label.
func ParseVersionLabel(label string) (string, string) {
// versions may look like v4.2-abcd or v4.2-alpine-abcd
idx := strings.LastIndex(label, "-")
version := label[:idx]
digest := label[idx+1:]
logrus.Debugf("parsed '%s' as version from '%s'", version, label)
logrus.Debugf("parsed '%s' as digest from '%s'", digest, label)
return version, digest
}
// ParseVersionName parses a $STACK_NAME_$SERVICE_NAME service label.
func ParseServiceName(label string) string { func ParseServiceName(label string) string {
idx := strings.LastIndex(label, "_") idx := strings.LastIndex(label, "_")
serviceName := label[idx+1:] serviceName := label[idx+1:]
logrus.Debugf("parsed %s as service name from %s", serviceName, label) logrus.Debugf("parsed '%s' as service name from '%s'", serviceName, label)
return serviceName return serviceName
} }

View File

@ -1,78 +0,0 @@
package autocomplete
import (
"fmt"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/recipe"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
)
// AppNameComplete copletes app names.
func AppNameComplete(c *cli.Context) {
appNames, err := config.GetAppNames()
if err != nil {
logrus.Warn(err)
}
if c.NArg() > 0 {
return
}
for _, a := range appNames {
fmt.Println(a)
}
}
// RecipeNameComplete completes recipe names.
func RecipeNameComplete(c *cli.Context) {
catl, err := recipe.ReadRecipeCatalogue(false)
if err != nil {
logrus.Warn(err)
}
if c.NArg() > 0 {
return
}
for name := range catl {
fmt.Println(name)
}
}
// ServerNameComplete completes server names.
func ServerNameComplete(c *cli.Context) {
files, err := config.LoadAppFiles("")
if err != nil {
logrus.Fatal(err)
}
if c.NArg() > 0 {
return
}
for _, appFile := range files {
fmt.Println(appFile.Server)
}
}
// SubcommandComplete completes sub-commands.
func SubcommandComplete(c *cli.Context) {
if c.NArg() > 0 {
return
}
subcmds := []string{
"app",
"autocomplete",
"catalogue",
"recipe",
"server",
"upgrade",
}
for _, cmd := range subcmds {
fmt.Println(cmd)
}
}

View File

@ -1,127 +1,500 @@
// Package catalogue provides ways of interacting with recipe catalogues which
// are JSON data structures which contain meta information about recipes (e.g.
// what versions of the Nextcloud recipe are available?).
package catalogue package catalogue
import ( import (
"encoding/json"
"fmt" "fmt"
"io/ioutil"
"net/http"
"os" "os"
"path" "path"
"strings" "strings"
"time"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
gitPkg "coopcloud.tech/abra/pkg/git" "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/web"
"github.com/docker/distribution/reference"
"github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
// CatalogueSkipList is all the repos that are not recipes. // RecipeCatalogueURL is the only current recipe catalogue available.
var CatalogueSkipList = map[string]bool{ const RecipeCatalogueURL = "https://apps.coopcloud.tech"
"abra": true,
"abra-apps": true, // ReposMetadataURL is the recipe repository metadata
"abra-aur": true, const ReposMetadataURL = "https://git.coopcloud.tech/api/v1/orgs/coop-cloud/repos"
"abra-bash": true,
"abra-capsul": true, // image represents a recipe container image.
"abra-gandi": true, type image struct {
"abra-hetzner": true, Image string `json:"image"`
"abra-integration-test-recipe": true, Rating string `json:"rating"`
"apps": true, Source string `json:"source"`
"aur-abra-git": true, URL string `json:"url"`
"auto-mirror": true,
"auto-recipes-catalogue-json": true,
"backup-bot": true,
"backup-bot-two": true,
"beta.coopcloud.tech": true,
"comrade-renovate-bot": true,
"coopcloud.tech": true,
"coturn": true,
"docker-cp-deploy": true,
"docker-dind-bats-kcov": true,
"docs.coopcloud.tech": true,
"drone-abra": true,
"example": true,
"gardening": true,
"go-abra": true,
"organising": true,
"pyabra": true,
"radicle-seed-node": true,
"recipes-catalogue-json": true,
"recipes-wishlist": true,
"recipes.coopcloud.tech": true,
"stack-ssh-deploy": true,
"swarm-cronjob": true,
"tagcmp": true,
"traefik-cert-dumper": true,
"tyop": true,
} }
// EnsureCatalogue ensures that the catalogue is cloned locally & present. // features represent what top-level features a recipe supports (e.g. does this
func EnsureCatalogue() error { // recipe support backups?).
catalogueDir := path.Join(config.ABRA_DIR, "catalogue") type features struct {
if _, err := os.Stat(catalogueDir); err != nil && os.IsNotExist(err) { Backups string `json:"backups"`
url := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, config.CATALOGUE_JSON_REPO_NAME) Email string `json:"email"`
if err := gitPkg.Clone(catalogueDir, url); err != nil { Healthcheck string `json:"healthcheck"`
return err Image image `json:"image"`
Status int `json:"status"`
Tests string `json:"tests"`
} }
logrus.Debugf("cloned catalogue repository to %s", catalogueDir) // tag represents a git tag.
type tag = string
// service represents a service within a recipe.
type service = string
// ServiceMeta represents meta info associated with a service.
type ServiceMeta struct {
Digest string `json:"digest"`
Image string `json:"image"`
Tag string `json:"tag"`
} }
return nil // RecipeVersions are the versions associated with a recipe.
type RecipeVersions []map[tag]map[service]ServiceMeta
// RecipeMeta represents metadata for a recipe in the abra catalogue.
type RecipeMeta struct {
Category string `json:"category"`
DefaultBranch string `json:"default_branch"`
Description string `json:"description"`
Features features `json:"features"`
Icon string `json:"icon"`
Name string `json:"name"`
Repository string `json:"repository"`
Versions RecipeVersions `json:"versions"`
Website string `json:"website"`
} }
// EnsureIsClean makes sure that the catalogue has no unstaged changes. // LatestVersion returns the latest version of a recipe.
func EnsureIsClean() error { func (r RecipeMeta) LatestVersion() string {
isClean, err := gitPkg.IsClean(config.CATALOGUE_DIR) var version string
// apps.json versions are sorted so the last key is latest
latest := r.Versions[len(r.Versions)-1]
for tag := range latest {
version = tag
}
logrus.Debugf("choosing '%s' as latest version of '%s'", version, r.Name)
return version
}
// Name represents a recipe name.
type Name = string
// RecipeCatalogue represents the entire recipe catalogue.
type RecipeCatalogue map[Name]RecipeMeta
// Flatten converts AppCatalogue to slice
func (r RecipeCatalogue) Flatten() []RecipeMeta {
recipes := make([]RecipeMeta, 0, len(r))
for name := range r {
recipes = append(recipes, r[name])
}
return recipes
}
// ByRecipeName sorts recipes by name.
type ByRecipeName []RecipeMeta
func (r ByRecipeName) Len() int { return len(r) }
func (r ByRecipeName) Swap(i, j int) { r[i], r[j] = r[j], r[i] }
func (r ByRecipeName) Less(i, j int) bool {
return strings.ToLower(r[i].Name) < strings.ToLower(r[j].Name)
}
// recipeCatalogueFSIsLatest checks whether the recipe catalogue stored locally
// is up to date.
func recipeCatalogueFSIsLatest() (bool, error) {
httpClient := &http.Client{Timeout: web.Timeout}
res, err := httpClient.Head(RecipeCatalogueURL)
if err != nil {
return false, err
}
lastModified := res.Header["Last-Modified"][0]
parsed, err := time.Parse(time.RFC1123, lastModified)
if err != nil {
return false, err
}
info, err := os.Stat(config.APPS_JSON)
if err != nil {
if os.IsNotExist(err) {
logrus.Debugf("no recipe catalogue found in file system cache")
return false, nil
}
return false, err
}
localModifiedTime := info.ModTime().Unix()
remoteModifiedTime := parsed.Unix()
if localModifiedTime < remoteModifiedTime {
logrus.Debug("file system cached recipe catalogue is out-of-date")
return false, nil
}
logrus.Debug("file system cached recipe catalogue is up-to-date")
return true, nil
}
// ReadRecipeCatalogue reads the recipe catalogue.
func ReadRecipeCatalogue() (RecipeCatalogue, error) {
recipes := make(RecipeCatalogue)
recipeFSIsLatest, err := recipeCatalogueFSIsLatest()
if err != nil {
return nil, err
}
if !recipeFSIsLatest {
logrus.Debugf("reading recipe catalogue from web to get latest")
if err := readRecipeCatalogueWeb(&recipes); err != nil {
return nil, err
}
return recipes, nil
}
logrus.Debugf("reading recipe catalogue from file system cache to get latest")
if err := readRecipeCatalogueFS(&recipes); err != nil {
return nil, err
}
return recipes, nil
}
// readRecipeCatalogueFS reads the catalogue from the file system.
func readRecipeCatalogueFS(target interface{}) error {
recipesJSONFS, err := ioutil.ReadFile(config.APPS_JSON)
if err != nil { if err != nil {
return err return err
} }
if !isClean { if err := json.Unmarshal(recipesJSONFS, &target); err != nil {
msg := "%s has locally unstaged changes? please commit/remove your changes before proceeding" return err
return fmt.Errorf(msg, config.CATALOGUE_DIR)
} }
logrus.Debugf("read recipe catalogue from file system cache in '%s'", config.APPS_JSON)
return nil return nil
} }
// EnsureUpToDate ensures that the local catalogue is up to date. // readRecipeCatalogueWeb reads the catalogue from the web.
func EnsureUpToDate() error { func readRecipeCatalogueWeb(target interface{}) error {
repo, err := git.PlainOpen(config.CATALOGUE_DIR) if err := web.ReadJSON(RecipeCatalogueURL, &target); err != nil {
return err
}
recipesJSON, err := json.MarshalIndent(target, "", " ")
if err != nil { if err != nil {
return err return err
} }
remotes, err := repo.Remotes() if err := ioutil.WriteFile(config.APPS_JSON, recipesJSON, 0644); err != nil {
if err != nil {
return err return err
} }
if len(remotes) == 0 { logrus.Debugf("read recipe catalogue from web at '%s'", RecipeCatalogueURL)
msg := "cannot ensure %s is up-to-date, no git remotes configured"
logrus.Debugf(msg, config.CATALOGUE_DIR)
return nil return nil
} }
// VersionsOfService lists the version of a service.
func VersionsOfService(recipe, serviceName string) ([]string, error) {
catalogue, err := ReadRecipeCatalogue()
if err != nil {
return nil, err
}
rec, ok := catalogue[recipe]
if !ok {
return nil, fmt.Errorf("recipe '%s' does not exist?", recipe)
}
versions := []string{}
alreadySeen := make(map[string]bool)
for _, serviceVersion := range rec.Versions {
for tag := range serviceVersion {
if _, ok := alreadySeen[tag]; !ok {
alreadySeen[tag] = true
versions = append(versions, tag)
}
}
}
logrus.Debugf("detected versions '%s' for '%s'", strings.Join(versions, ", "), recipe)
return versions, nil
}
// GetRecipeMeta retrieves the recipe metadata from the recipe catalogue.
func GetRecipeMeta(recipeName string) (RecipeMeta, error) {
catl, err := ReadRecipeCatalogue()
if err != nil {
return RecipeMeta{}, err
}
recipeMeta, ok := catl[recipeName]
if !ok {
err := fmt.Errorf("recipe '%s' does not exist?", recipeName)
return RecipeMeta{}, err
}
if err := recipe.EnsureExists(recipeName); err != nil {
return RecipeMeta{}, err
}
logrus.Debugf("recipe metadata retrieved for '%s'", recipeName)
return recipeMeta, nil
}
// RepoMeta is a single recipe repo metadata.
type RepoMeta struct {
ID int `json:"id"`
Owner Owner
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description"`
Empty bool `json:"empty"`
Private bool `json:"private"`
Fork bool `json:"fork"`
Template bool `json:"template"`
Parent interface{} `json:"parent"`
Mirror bool `json:"mirror"`
Size int `json:"size"`
HTMLURL string `json:"html_url"`
SSHURL string `json:"ssh_url"`
CloneURL string `json:"clone_url"`
OriginalURL string `json:"original_url"`
Website string `json:"website"`
StarsCount int `json:"stars_count"`
ForksCount int `json:"forks_count"`
WatchersCount int `json:"watchers_count"`
OpenIssuesCount int `json:"open_issues_count"`
OpenPRCount int `json:"open_pr_counter"`
ReleaseCounter int `json:"release_counter"`
DefaultBranch string `json:"default_branch"`
Archived bool `json:"archived"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Permissions Permissions
HasIssues bool `json:"has_issues"`
InternalTracker InternalTracker
HasWiki bool `json:"has_wiki"`
HasPullRequests bool `json:"has_pull_requests"`
HasProjects bool `json:"has_projects"`
IgnoreWhitespaceConflicts bool `json:"ignore_whitespace_conflicts"`
AllowMergeCommits bool `json:"allow_merge_commits"`
AllowRebase bool `json:"allow_rebase"`
AllowRebaseExplicit bool `json:"allow_rebase_explicit"`
AllowSquashMerge bool `json:"allow_squash_merge"`
AvatarURL string `json:"avatar_url"`
Internal bool `json:"internal"`
MirrorInterval string `json:"mirror_interval"`
}
// Owner is the repo organisation owner metadata.
type Owner struct {
ID int `json:"id"`
Login string `json:"login"`
FullName string `json:"full_name"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
Language string `json:"language"`
IsAdmin bool `json:"is_admin"`
LastLogin string `json:"last_login"`
Created string `json:"created"`
Restricted bool `json:"restricted"`
Username string `json:"username"`
}
// Permissions is perms metadata for a repo.
type Permissions struct {
Admin bool `json:"admin"`
Push bool `json:"push"`
Pull bool `json:"pull"`
}
// InternalTracker is issue tracker metadata for a repo.
type InternalTracker struct {
EnableTimeTracker bool `json:"enable_time_tracker"`
AllowOnlyContributorsToTrackTime bool `json:"allow_only_contributors_to_track_time"`
EnableIssuesDependencies bool `json:"enable_issue_dependencies"`
}
// RepoCatalogue represents all the recipe repo metadata.
type RepoCatalogue map[string]RepoMeta
// ReadReposMetadata retrieves coop-cloud/... repo metadata from Gitea.
func ReadReposMetadata() (RepoCatalogue, error) {
reposMeta := make(RepoCatalogue)
pageIdx := 1
for {
var reposList []RepoMeta
pagedURL := fmt.Sprintf("%s?page=%v", ReposMetadataURL, pageIdx)
logrus.Debugf("fetching repo metadata from '%s'", pagedURL)
if err := web.ReadJSON(pagedURL, &reposList); err != nil {
return reposMeta, err
}
if len(reposList) == 0 {
break
}
for idx, repo := range reposList {
reposMeta[repo.Name] = reposList[idx]
}
pageIdx++
}
return reposMeta, nil
}
// GetRecipeVersions retrieves all recipe versions.
func GetRecipeVersions(recipeName string) (RecipeVersions, error) {
versions := RecipeVersions{}
recipeDir := path.Join(config.ABRA_DIR, "apps", recipeName)
logrus.Debugf("attempting to open git repository in '%s'", recipeDir)
repo, err := git.PlainOpen(recipeDir)
if err != nil {
return versions, err
}
worktree, err := repo.Worktree() worktree, err := repo.Worktree()
if err != nil { if err != nil {
logrus.Fatal(err)
}
gitTags, err := repo.Tags()
if err != nil {
return versions, err
}
if err := gitTags.ForEach(func(ref *plumbing.Reference) (err error) {
tag := strings.TrimPrefix(string(ref.Name()), "refs/tags/")
logrus.Debugf("processing '%s' for '%s'", tag, recipeName)
checkOutOpts := &git.CheckoutOptions{
Create: false,
Force: true,
Branch: plumbing.ReferenceName(ref.Name()),
}
if err := worktree.Checkout(checkOutOpts); err != nil {
logrus.Debugf("failed to check out '%s' in '%s'", tag, recipeDir)
return err return err
} }
branch, err := gitPkg.CheckoutDefaultBranch(repo, config.CATALOGUE_DIR) logrus.Debugf("successfully checked out '%s' in '%s'", ref.Name(), recipeDir)
recipe, err := recipe.Get(recipeName)
if err != nil { if err != nil {
return err return err
} }
opts := &git.PullOptions{ versionMeta := make(map[string]ServiceMeta)
Force: true, for _, service := range recipe.Config.Services {
ReferenceName: branch,
}
if err := worktree.Pull(opts); err != nil { img, err := reference.ParseNormalizedNamed(service.Image)
if !strings.Contains(err.Error(), "already up-to-date") { if err != nil {
return err return err
} }
path := reference.Path(img)
if strings.Contains(path, "library") {
path = strings.Split(path, "/")[1]
} }
logrus.Debugf("fetched latest git changes for %s", config.CATALOGUE_DIR) digest, err := client.GetTagDigest(img)
if err != nil {
return err
}
versionMeta[service.Name] = ServiceMeta{
Digest: digest,
Image: path,
Tag: img.(reference.NamedTagged).Tag(),
}
logrus.Debugf("collecting digest: '%s', image: '%s', tag: '%s'", digest, path, tag)
}
versions = append(versions, map[string]map[string]ServiceMeta{tag: versionMeta})
return nil return nil
}); err != nil {
return versions, err
}
branch := "master"
if _, err := repo.Branch("master"); err != nil {
if _, err := repo.Branch("main"); err != nil {
logrus.Debugf("failed to select branch in '%s'", recipeDir)
logrus.Fatal(err)
}
branch = "main"
}
refName := fmt.Sprintf("refs/heads/%s", branch)
checkOutOpts := &git.CheckoutOptions{
Create: false,
Force: true,
Branch: plumbing.ReferenceName(refName),
}
if err := worktree.Checkout(checkOutOpts); err != nil {
logrus.Debugf("failed to check out '%s' in '%s'", branch, recipeDir)
logrus.Fatal(err)
}
logrus.Debugf("switched back to '%s' in '%s'", branch, recipeDir)
logrus.Debugf("collected '%s' for '%s'", versions, recipeName)
return versions, nil
}
// GetRecipeCatalogueVersions list the recipe versions listed in the recipe catalogue.
func GetRecipeCatalogueVersions(recipeName string) ([]string, error) {
var versions []string
catl, err := ReadRecipeCatalogue()
if err != nil {
return versions, err
}
if recipeMeta, exists := catl[recipeName]; exists {
for _, versionMeta := range recipeMeta.Versions {
for tag := range versionMeta {
versions = append(versions, tag)
}
}
}
return versions, nil
} }

View File

@ -2,31 +2,24 @@
package client package client
import ( import (
"context"
"errors"
"fmt"
"net/http" "net/http"
"os" "os"
"time" "time"
contextPkg "coopcloud.tech/abra/pkg/context" contextPkg "coopcloud.tech/abra/pkg/context"
sshPkg "coopcloud.tech/abra/pkg/ssh"
commandconnPkg "coopcloud.tech/abra/pkg/upstream/commandconn" commandconnPkg "coopcloud.tech/abra/pkg/upstream/commandconn"
"github.com/docker/docker/client" "github.com/docker/docker/client"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
// New initiates a new Docker client. New client connections are validated so // New initiates a new Docker client.
// that we ensure connections via SSH to the daemon can succeed. It takes into func New(contextName string) (*client.Client, error) {
// account that you may only want the local client and not communicate via SSH.
// For this use-case, please pass "default" as the contextName.
func New(serverName string) (*client.Client, error) {
var clientOpts []client.Opt var clientOpts []client.Opt
if serverName != "default" { if contextName != "default" {
context, err := GetContext(serverName) context, err := GetContext(contextName)
if err != nil { if err != nil {
return nil, fmt.Errorf("unknown server, run \"abra server add %s\"?", serverName) return nil, err
} }
ctxEndpoint, err := contextPkg.GetContextEndpoint(context) ctxEndpoint, err := contextPkg.GetContextEndpoint(context)
@ -34,12 +27,9 @@ func New(serverName string) (*client.Client, error) {
return nil, err return nil, err
} }
helper, err := commandconnPkg.NewConnectionHelper(ctxEndpoint) helper := commandconnPkg.NewConnectionHelper(ctxEndpoint)
if err != nil {
return nil, err
}
httpClient := &http.Client{ httpClient := &http.Client{
// No tls, no proxy
Transport: &http.Transport{ Transport: &http.Transport{
DialContext: helper.Dialer, DialContext: helper.Dialer,
IdleConnTimeout: 30 * time.Second, IdleConnTimeout: 30 * time.Second,
@ -65,20 +55,7 @@ func New(serverName string) (*client.Client, error) {
return nil, err return nil, err
} }
logrus.Debugf("created client for %s", serverName) logrus.Debugf("created client for '%s'", contextName)
info, err := cl.Info(context.Background())
if err != nil {
return cl, sshPkg.Fatal(serverName, err)
}
if info.Swarm.LocalNodeState == "inactive" {
if serverName != "default" {
return cl, fmt.Errorf("swarm mode not enabled on %s?", serverName)
} else {
return cl, errors.New("swarm mode not enabled on local server?")
}
}
return cl, nil return cl, nil
} }

View File

@ -26,7 +26,7 @@ func CreateContext(contextName string, user string, port string) error {
if err := createContext(contextName, host); err != nil { if err := createContext(contextName, host); err != nil {
return err return err
} }
logrus.Debugf("created the %s context", contextName) logrus.Debugf("created the '%s' context", contextName)
return nil return nil
} }
@ -72,6 +72,8 @@ func DeleteContext(name string) error {
return err return err
} }
// remove any context that might be loaded
// TODO: Check if the context we are removing is the active one rather than doing it all the time
cfg := dConfig.LoadDefaultConfigFile(nil) cfg := dConfig.LoadDefaultConfigFile(nil)
cfg.CurrentContext = "" cfg.CurrentContext = ""
if err := cfg.Save(); err != nil { if err := cfg.Save(); err != nil {

View File

@ -1,28 +1,170 @@
package client package client
import ( import (
"context" "encoding/json"
"fmt" "fmt"
"io/ioutil"
"net/http"
"strings"
"github.com/containers/image/docker" "coopcloud.tech/abra/pkg/web"
"github.com/containers/image/types"
"github.com/docker/distribution/reference" "github.com/docker/distribution/reference"
) )
// GetRegistryTags retrieves all tags of an image from a container registry. type RawTag struct {
func GetRegistryTags(img reference.Named) ([]string, error) { Layer string
var tags []string Name string
ref, err := docker.ParseReference(fmt.Sprintf("//%s", img))
if err != nil {
return tags, fmt.Errorf("failed to parse image %s, saw: %s", img, err.Error())
} }
ctx := context.Background() type RawTags []RawTag
tags, err = docker.GetRepositoryTags(ctx, &types.SystemContext{}, ref)
if err != nil { var registryURL = "https://registry.hub.docker.com/v1/repositories/%s/tags"
func GetRegistryTags(image string) (RawTags, error) {
var tags RawTags
tagsUrl := fmt.Sprintf(registryURL, image)
if err := web.ReadJSON(tagsUrl, &tags); err != nil {
return tags, err return tags, err
} }
return tags, nil return tags, nil
} }
// getRegv2Token retrieves a registry v2 authentication token.
func getRegv2Token(image reference.Named) (string, error) {
img := reference.Path(image)
authTokenURL := fmt.Sprintf("https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull", img)
req, err := http.NewRequest("GET", authTokenURL, nil)
if err != nil {
return "", err
}
client := &http.Client{Timeout: web.Timeout}
res, err := client.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
_, err := ioutil.ReadAll(res.Body)
if err != nil {
return "", err
}
}
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return "", nil
}
tokenRes := struct {
Token string
Expiry string
Issued string
}{}
if err := json.Unmarshal(body, &tokenRes); err != nil {
return "", err
}
return tokenRes.Token, nil
}
// GetTagDigest retrieves an image digest from a v2 registry
func GetTagDigest(image reference.Named) (string, error) {
img := reference.Path(image)
tag := image.(reference.NamedTagged).Tag()
manifestURL := fmt.Sprintf("https://index.docker.io/v2/%s/manifests/%s", img, tag)
req, err := http.NewRequest("GET", manifestURL, nil)
if err != nil {
return "", err
}
token, err := getRegv2Token(image)
if err != nil {
return "", err
}
req.Header = http.Header{
"Accept": []string{
"application/vnd.docker.distribution.manifest.v2+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
},
"Authorization": []string{fmt.Sprintf("Bearer %s", token)},
}
client := &http.Client{Timeout: web.Timeout}
res, err := client.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
_, err := ioutil.ReadAll(res.Body)
if err != nil {
return "", err
}
}
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return "", err
}
registryResT1 := struct {
SchemaVersion int
MediaType string
Manifests []struct {
MediaType string
Size int
Digest string
Platform struct {
Architecture string
Os string
}
}
}{}
registryResT2 := struct {
SchemaVersion int
MediaType string
Config struct {
MediaType string
Size int
Digest string
}
Layers []struct {
MediaType string
Size int
Digest string
}
}{}
if err := json.Unmarshal(body, &registryResT1); err != nil {
return "", err
}
var digest string
for _, manifest := range registryResT1.Manifests {
if string(manifest.Platform.Architecture) == "amd64" {
digest = strings.Split(manifest.Digest, ":")[1][:7]
}
}
if digest == "" {
if err := json.Unmarshal(body, &registryResT2); err != nil {
return "", err
}
digest = strings.Split(registryResT2.Config.Digest, ":")[1][:7]
}
if digest == "" {
return "", fmt.Errorf("Unable to retrieve amd64 digest for '%s'", image)
}
return digest, nil
}

View File

@ -4,14 +4,20 @@ import (
"context" "context"
"github.com/docker/docker/api/types/swarm" "github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/client"
) )
func StoreSecret(cl *client.Client, secretName, secretValue, server string) error { func StoreSecret(secretName, secretValue, server string) error {
cl, err := New(server)
if err != nil {
return err
}
ctx := context.Background()
ann := swarm.Annotations{Name: secretName} ann := swarm.Annotations{Name: secretName}
spec := swarm.SecretSpec{Annotations: ann, Data: []byte(secretValue)} spec := swarm.SecretSpec{Annotations: ann, Data: []byte(secretValue)}
if _, err := cl.SecretCreate(context.Background(), spec); err != nil { // We don't bother with the secret IDs for now
if _, err := cl.SecretCreate(ctx, spec); err != nil {
return err return err
} }

View File

@ -3,39 +3,49 @@ package client
import ( import (
"context" "context"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/volume" "github.com/sirupsen/logrus"
"github.com/docker/docker/client"
) )
func GetVolumes(cl *client.Client, ctx context.Context, server string, fs filters.Args) ([]*volume.Volume, error) { func GetVolumes(ctx context.Context, server string, appName string) ([]*types.Volume, error) {
volumeListOptions := volume.ListOptions{fs}
volumeListOKBody, err := cl.VolumeList(ctx, volumeListOptions) cl, err := New(server)
if err != nil {
return nil, err
}
fs := filters.NewArgs()
fs.Add("name", appName)
volumeListOKBody, err := cl.VolumeList(ctx, fs)
volumeList := volumeListOKBody.Volumes volumeList := volumeListOKBody.Volumes
if err != nil { if err != nil {
return volumeList, err logrus.Fatal(err)
} }
return volumeList, nil return volumeList, nil
} }
func GetVolumeNames(volumes []*volume.Volume) []string { func GetVolumeNames(volumes []*types.Volume) []string {
var volumeNames []string var volumeNames []string
for _, vol := range volumes { for _, vol := range volumes {
volumeNames = append(volumeNames, vol.Name) volumeNames = append(volumeNames, vol.Name)
} }
return volumeNames return volumeNames
} }
func RemoveVolumes(cl *client.Client, ctx context.Context, server string, volumeNames []string, force bool) error { func RemoveVolumes(ctx context.Context, server string, volumeNames []string, force bool) error {
cl, err := New(server)
if err != nil {
return err
}
for _, volName := range volumeNames { for _, volName := range volumeNames {
err := cl.VolumeRemove(ctx, volName, force) err := cl.VolumeRemove(ctx, volName, force)
if err != nil { if err != nil {
return err return err
} }
} }
return nil return nil
} }

View File

@ -8,7 +8,6 @@ import (
"strings" "strings"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/upstream/stack" "coopcloud.tech/abra/pkg/upstream/stack"
loader "coopcloud.tech/abra/pkg/upstream/stack" loader "coopcloud.tech/abra/pkg/upstream/stack"
composetypes "github.com/docker/cli/cli/compose/types" composetypes "github.com/docker/cli/cli/compose/types"
@ -17,26 +16,26 @@ import (
) )
// UpdateTag updates an image tag in-place on file system local compose files. // UpdateTag updates an image tag in-place on file system local compose files.
func UpdateTag(pattern, image, tag, recipeName string) (bool, error) { func UpdateTag(pattern, image, tag, recipeName string) error {
composeFiles, err := filepath.Glob(pattern) composeFiles, err := filepath.Glob(pattern)
if err != nil { if err != nil {
return false, err return err
} }
logrus.Debugf("considering %s config(s) for tag update", strings.Join(composeFiles, ", ")) logrus.Debugf("considering '%s' config(s) for tag update", strings.Join(composeFiles, ", "))
for _, composeFile := range composeFiles { for _, composeFile := range composeFiles {
opts := stack.Deploy{Composefiles: []string{composeFile}} opts := stack.Deploy{Composefiles: []string{composeFile}}
envSamplePath := path.Join(config.RECIPES_DIR, recipeName, ".env.sample") envSamplePath := path.Join(config.ABRA_DIR, "apps", recipeName, ".env.sample")
sampleEnv, err := config.ReadEnv(envSamplePath) sampleEnv, err := config.ReadEnv(envSamplePath)
if err != nil { if err != nil {
return false, err return err
} }
compose, err := loader.LoadComposefile(opts, sampleEnv) compose, err := loader.LoadComposefile(opts, sampleEnv)
if err != nil { if err != nil {
return false, err return err
} }
for _, service := range compose.Services { for _, service := range compose.Services {
@ -46,42 +45,40 @@ func UpdateTag(pattern, image, tag, recipeName string) (bool, error) {
img, _ := reference.ParseNormalizedNamed(service.Image) img, _ := reference.ParseNormalizedNamed(service.Image)
if err != nil { if err != nil {
return false, err return err
} }
var composeTag string composeImage := reference.Path(img)
switch img.(type) { if strings.Contains(composeImage, "library") {
case reference.NamedTagged: // ParseNormalizedNamed prepends 'library' to images like nginx:<tag>,
composeTag = img.(reference.NamedTagged).Tag() // postgres:<tag>, i.e. images which do not have a username in the
default: // first position of the string
logrus.Debugf("unable to parse %s, skipping", img) composeImage = strings.Split(composeImage, "/")[1]
continue
} }
composeTag := img.(reference.NamedTagged).Tag()
composeImage := formatter.StripTagMeta(reference.Path(img)) logrus.Debugf("parsed '%s' from '%s'", composeTag, service.Image)
logrus.Debugf("parsed %s from %s", composeTag, service.Image)
if image == composeImage { if image == composeImage {
bytes, err := ioutil.ReadFile(composeFile) bytes, err := ioutil.ReadFile(composeFile)
if err != nil { if err != nil {
return false, err return err
} }
old := fmt.Sprintf("%s:%s", composeImage, composeTag) old := fmt.Sprintf("%s:%s", composeImage, composeTag)
new := fmt.Sprintf("%s:%s", composeImage, tag) new := fmt.Sprintf("%s:%s", composeImage, tag)
replacedBytes := strings.Replace(string(bytes), old, new, -1) replacedBytes := strings.Replace(string(bytes), old, new, -1)
logrus.Debugf("updating %s to %s in %s", old, new, compose.Filename) logrus.Debugf("updating '%s' to '%s' in '%s'", old, new, compose.Filename)
if err := ioutil.WriteFile(compose.Filename, []byte(replacedBytes), 0764); err != nil { if err := ioutil.WriteFile(compose.Filename, []byte(replacedBytes), 0644); err != nil {
return false, err return err
} }
} }
} }
} }
return false, nil return nil
} }
// UpdateLabel updates a label in-place on file system local compose files. // UpdateLabel updates a label in-place on file system local compose files.
@ -91,12 +88,12 @@ func UpdateLabel(pattern, serviceName, label, recipeName string) error {
return err return err
} }
logrus.Debugf("considering %s config(s) for label update", strings.Join(composeFiles, ", ")) logrus.Debugf("considering '%s' config(s) for label update", strings.Join(composeFiles, ", "))
for _, composeFile := range composeFiles { for _, composeFile := range composeFiles {
opts := stack.Deploy{Composefiles: []string{composeFile}} opts := stack.Deploy{Composefiles: []string{composeFile}}
envSamplePath := path.Join(config.RECIPES_DIR, recipeName, ".env.sample") envSamplePath := path.Join(config.ABRA_DIR, "apps", recipeName, ".env.sample")
sampleEnv, err := config.ReadEnv(envSamplePath) sampleEnv, err := config.ReadEnv(envSamplePath)
if err != nil { if err != nil {
return err return err
@ -122,7 +119,7 @@ func UpdateLabel(pattern, serviceName, label, recipeName string) error {
discovered := false discovered := false
for oldLabel, value := range service.Deploy.Labels { for oldLabel, value := range service.Deploy.Labels {
if strings.HasPrefix(oldLabel, "coop-cloud") && strings.Contains(oldLabel, "version") { if strings.HasPrefix(oldLabel, "coop-cloud") {
discovered = true discovered = true
bytes, err := ioutil.ReadFile(composeFile) bytes, err := ioutil.ReadFile(composeFile)
@ -133,25 +130,19 @@ func UpdateLabel(pattern, serviceName, label, recipeName string) error {
old := fmt.Sprintf("coop-cloud.${STACK_NAME}.version=%s", value) old := fmt.Sprintf("coop-cloud.${STACK_NAME}.version=%s", value)
replacedBytes := strings.Replace(string(bytes), old, label, -1) replacedBytes := strings.Replace(string(bytes), old, label, -1)
if old == label {
logrus.Warnf("%s is already set, nothing to do?", label)
return nil
}
logrus.Debugf("updating %s to %s in %s", old, label, compose.Filename) logrus.Debugf("updating %s to %s in %s", old, label, compose.Filename)
if err := ioutil.WriteFile(compose.Filename, []byte(replacedBytes), 0764); err != nil { if err := ioutil.WriteFile(compose.Filename, []byte(replacedBytes), 0644); err != nil {
return err return err
} }
logrus.Infof("synced label %s to service %s", label, serviceName)
} }
} }
if !discovered { if !discovered {
logrus.Warn("no existing label found, automagic insertion not supported yet") logrus.Warn("no existing label found, cannot continue...")
logrus.Fatalf("add '- \"%s\"' manually to the 'app' service in %s", label, composeFile) logrus.Fatalf("add '%s' manually, automagic insertion not supported yet", label)
} }
} }
return nil return nil

View File

@ -1,22 +1,19 @@
package config package config
import ( import (
"errors"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"os" "os"
"path" "path"
"strconv"
"strings" "strings"
"github.com/schollz/progressbar/v3" "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/pkg/ssh"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/upstream/convert" "coopcloud.tech/abra/pkg/upstream/convert"
loader "coopcloud.tech/abra/pkg/upstream/stack" loader "coopcloud.tech/abra/pkg/upstream/stack"
stack "coopcloud.tech/abra/pkg/upstream/stack" stack "coopcloud.tech/abra/pkg/upstream/stack"
composetypes "github.com/docker/cli/cli/compose/types" composetypes "github.com/docker/cli/cli/compose/types"
"github.com/docker/docker/api/types/filters"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
@ -40,75 +37,24 @@ type AppFiles map[AppName]AppFile
// App reprents an app with its env file read into memory // App reprents an app with its env file read into memory
type App struct { type App struct {
Name AppName Name AppName
Recipe string Type string
Domain string Domain string
Env AppEnv Env AppEnv
Server string Server string
Path string Path string
} }
// StackName gets whatever the docker safe (uses the right delimiting // StackName gets what the docker safe stack name is for the app
// character, e.g. "_") stack name is for the app. In general, you don't want
// to use this to show anything to end-users, you want use a.Name instead.
func (a App) StackName() string { func (a App) StackName() string {
if _, exists := a.Env["STACK_NAME"]; exists { if _, exists := a.Env["STACK_NAME"]; exists {
return a.Env["STACK_NAME"] return a.Env["STACK_NAME"]
} }
stackName := SanitiseAppName(a.Name) stackName := SanitiseAppName(a.Name)
if len(stackName) > 45 {
logrus.Debugf("trimming %s to %s to avoid runtime limits", stackName, stackName[:45])
stackName = stackName[:45]
}
a.Env["STACK_NAME"] = stackName a.Env["STACK_NAME"] = stackName
return stackName return stackName
} }
// Filters retrieves exact app filters for querying the container runtime. Due // SORTING TYPES
// to upstream issues, filtering works different depending on what you're
// querying. So, for example, secrets don't work with regex! The caller needs
// to implement their own validation that the right secrets are matched. In
// order to handle these cases, we provide the `appendServiceNames` /
// `exactMatch` modifiers.
func (a App) Filters(appendServiceNames, exactMatch bool) (filters.Args, error) {
filters := filters.NewArgs()
composeFiles, err := GetAppComposeFiles(a.Recipe, a.Env)
if err != nil {
return filters, err
}
opts := stack.Deploy{Composefiles: composeFiles}
compose, err := GetAppComposeConfig(a.Recipe, opts, a.Env)
if err != nil {
return filters, err
}
for _, service := range compose.Services {
var filter string
if appendServiceNames {
if exactMatch {
filter = fmt.Sprintf("^%s_%s", a.StackName(), service.Name)
} else {
filter = fmt.Sprintf("%s_%s", a.StackName(), service.Name)
}
} else {
if exactMatch {
filter = fmt.Sprintf("^%s", a.StackName())
} else {
filter = fmt.Sprintf("%s", a.StackName())
}
}
filters.Add("name", filter)
}
return filters, nil
}
// ByServer sort a slice of Apps // ByServer sort a slice of Apps
type ByServer []App type ByServer []App
@ -119,25 +65,25 @@ func (a ByServer) Less(i, j int) bool {
return strings.ToLower(a[i].Server) < strings.ToLower(a[j].Server) return strings.ToLower(a[i].Server) < strings.ToLower(a[j].Server)
} }
// ByServerAndRecipe sort a slice of Apps // ByServerAndType sort a slice of Apps
type ByServerAndRecipe []App type ByServerAndType []App
func (a ByServerAndRecipe) Len() int { return len(a) } func (a ByServerAndType) Len() int { return len(a) }
func (a ByServerAndRecipe) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a ByServerAndType) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByServerAndRecipe) Less(i, j int) bool { func (a ByServerAndType) Less(i, j int) bool {
if a[i].Server == a[j].Server { if a[i].Server == a[j].Server {
return strings.ToLower(a[i].Recipe) < strings.ToLower(a[j].Recipe) return strings.ToLower(a[i].Type) < strings.ToLower(a[j].Type)
} }
return strings.ToLower(a[i].Server) < strings.ToLower(a[j].Server) return strings.ToLower(a[i].Server) < strings.ToLower(a[j].Server)
} }
// ByRecipe sort a slice of Apps // ByType sort a slice of Apps
type ByRecipe []App type ByType []App
func (a ByRecipe) Len() int { return len(a) } func (a ByType) Len() int { return len(a) }
func (a ByRecipe) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a ByType) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByRecipe) Less(i, j int) bool { func (a ByType) Less(i, j int) bool {
return strings.ToLower(a[i].Recipe) < strings.ToLower(a[j].Recipe) return strings.ToLower(a[i].Type) < strings.ToLower(a[j].Type)
} }
// ByName sort a slice of Apps // ByName sort a slice of Apps
@ -152,14 +98,14 @@ func (a ByName) Less(i, j int) bool {
func readAppEnvFile(appFile AppFile, name AppName) (App, error) { func readAppEnvFile(appFile AppFile, name AppName) (App, error) {
env, err := ReadEnv(appFile.Path) env, err := ReadEnv(appFile.Path)
if err != nil { if err != nil {
return App{}, fmt.Errorf("env file for %s couldn't be read: %s", name, err.Error()) return App{}, fmt.Errorf("env file for '%s' couldn't be read: %s", name, err.Error())
} }
logrus.Debugf("read env %s from %s", env, appFile.Path) logrus.Debugf("read env '%s' from '%s'", env, appFile.Path)
app, err := newApp(env, name, appFile) app, err := newApp(env, name, appFile)
if err != nil { if err != nil {
return App{}, fmt.Errorf("env file for %s has issues: %s", name, err.Error()) return App{}, fmt.Errorf("env file for '%s' has issues: %s", name, err.Error())
} }
return app, nil return app, nil
@ -167,69 +113,68 @@ func readAppEnvFile(appFile AppFile, name AppName) (App, error) {
// newApp creates new App object // newApp creates new App object
func newApp(env AppEnv, name string, appFile AppFile) (App, error) { func newApp(env AppEnv, name string, appFile AppFile) (App, error) {
// Checking for type as it is required - apps wont work without it
domain := env["DOMAIN"] domain := env["DOMAIN"]
apptype, ok := env["TYPE"]
recipe, exists := env["RECIPE"] if !ok {
if !exists { return App{}, errors.New("missing TYPE variable")
recipe, exists = env["TYPE"]
if !exists {
return App{}, fmt.Errorf("%s is missing the TYPE env var?", name)
}
} }
return App{ return App{
Name: name, Name: name,
Domain: domain, Domain: domain,
Recipe: recipe, Type: apptype,
Env: env, Env: env,
Server: appFile.Server, Server: appFile.Server,
Path: appFile.Path, Path: appFile.Path,
}, nil }, nil
} }
// LoadAppFiles gets all app files for a given set of servers or all servers. // LoadAppFiles gets all app files for a given set of servers or all servers
func LoadAppFiles(servers ...string) (AppFiles, error) { func LoadAppFiles(servers ...string) (AppFiles, error) {
appFiles := make(AppFiles) appFiles := make(AppFiles)
if len(servers) == 1 { if len(servers) == 1 {
if servers[0] == "" { if servers[0] == "" {
// Empty servers flag, one string will always be passed // Empty servers flag, one string will always be passed
var err error var err error
servers, err = GetAllFoldersInDirectory(SERVERS_DIR) servers, err = getAllFoldersInDirectory(ABRA_SERVER_FOLDER)
if err != nil { if err != nil {
return appFiles, err return nil, err
} }
} }
} }
logrus.Debugf("collecting metadata from %v servers: %s", len(servers), strings.Join(servers, ", ")) logrus.Debugf("collecting metadata from '%v' servers: '%s'", len(servers), strings.Join(servers, ", "))
if err := EnsureHostKeysAllServers(servers...); err != nil {
return nil, err
}
for _, server := range servers { for _, server := range servers {
serverDir := path.Join(SERVERS_DIR, server) serverDir := path.Join(ABRA_SERVER_FOLDER, server)
files, err := GetAllFilesInDirectory(serverDir) files, err := getAllFilesInDirectory(serverDir)
if err != nil { if err != nil {
return appFiles, fmt.Errorf("server %s doesn't exist? Run \"abra server ls\" to check", server) return nil, err
} }
for _, file := range files { for _, file := range files {
appName := strings.TrimSuffix(file.Name(), ".env") appName := strings.TrimSuffix(file.Name(), ".env")
appFilePath := path.Join(SERVERS_DIR, server, file.Name()) appFilePath := path.Join(ABRA_SERVER_FOLDER, server, file.Name())
appFiles[appName] = AppFile{ appFiles[appName] = AppFile{
Path: appFilePath, Path: appFilePath,
Server: server, Server: server,
} }
} }
} }
return appFiles, nil return appFiles, nil
} }
// GetApp loads an apps settings, reading it from file, in preparation to use // GetApp loads an apps settings, reading it from file, in preparation to use it
// it. It should only be used when ready to use the env file to keep IO //
// operations down. // ONLY use when ready to use the env file to keep IO down
func GetApp(apps AppFiles, name AppName) (App, error) { func GetApp(apps AppFiles, name AppName) (App, error) {
appFile, exists := apps[name] appFile, exists := apps[name]
if !exists { if !exists {
return App{}, fmt.Errorf("cannot find app with name %s", name) return App{}, fmt.Errorf("cannot find app with name '%s'", name)
} }
app, err := readAppEnvFile(appFile, name) app, err := readAppEnvFile(appFile, name)
@ -240,9 +185,8 @@ func GetApp(apps AppFiles, name AppName) (App, error) {
return app, nil return app, nil
} }
// GetApps returns a slice of Apps with their env files read from a given // GetApps returns a slice of Apps with their env files read from a given slice of AppFiles
// slice of AppFiles. func GetApps(appFiles AppFiles) ([]App, error) {
func GetApps(appFiles AppFiles, recipeFilter string) ([]App, error) {
var apps []App var apps []App
for name := range appFiles { for name := range appFiles {
@ -250,15 +194,8 @@ func GetApps(appFiles AppFiles, recipeFilter string) ([]App, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if recipeFilter != "" {
if app.Recipe == recipeFilter {
apps = append(apps, app) apps = append(apps, app)
} }
} else {
apps = append(apps, app)
}
}
return apps, nil return apps, nil
} }
@ -277,13 +214,13 @@ func GetAppServiceNames(appName string) ([]string, error) {
return serviceNames, err return serviceNames, err
} }
composeFiles, err := GetAppComposeFiles(app.Recipe, app.Env) composeFiles, err := GetAppComposeFiles(app.Type, app.Env)
if err != nil { if err != nil {
return serviceNames, err return serviceNames, err
} }
opts := stack.Deploy{Composefiles: composeFiles} opts := stack.Deploy{Composefiles: composeFiles}
compose, err := GetAppComposeConfig(app.Recipe, opts, app.Env) compose, err := GetAppComposeConfig(app.Type, opts, app.Env)
if err != nil { if err != nil {
return serviceNames, err return serviceNames, err
} }
@ -304,7 +241,7 @@ func GetAppNames() ([]string, error) {
return appNames, err return appNames, err
} }
apps, err := GetApps(appFiles, "") apps, err := GetApps(appFiles)
if err != nil { if err != nil {
return appNames, err return appNames, err
} }
@ -316,85 +253,61 @@ func GetAppNames() ([]string, error) {
return appNames, nil return appNames, nil
} }
// TemplateAppEnvSample copies the example env file for the app into the users // TemplateAppEnvSample copies the example env file for the app into the users env files
// env files. func TemplateAppEnvSample(appType, appName, server, domain, recipe string) error {
func TemplateAppEnvSample(recipeName, appName, server, domain string) error { envSamplePath := path.Join(ABRA_DIR, "apps", appType, ".env.sample")
envSamplePath := path.Join(RECIPES_DIR, recipeName, ".env.sample")
envSample, err := ioutil.ReadFile(envSamplePath) envSample, err := ioutil.ReadFile(envSamplePath)
if err != nil { if err != nil {
return err return err
} }
appEnvPath := path.Join(ABRA_DIR, "servers", server, fmt.Sprintf("%s.env", appName)) appEnvPath := path.Join(ABRA_DIR, "servers", server, fmt.Sprintf("%s.env", appName))
if _, err := os.Stat(appEnvPath); !os.IsNotExist(err) { if _, err := os.Stat(appEnvPath); err == nil {
return fmt.Errorf("%s already exists?", appEnvPath) return fmt.Errorf("%s already exists?", appEnvPath)
} }
err = ioutil.WriteFile(appEnvPath, envSample, 0664) envSample = []byte(strings.Replace(string(envSample), fmt.Sprintf("%s.example.com", recipe), domain, -1))
envSample = []byte(strings.Replace(string(envSample), "example.com", domain, -1))
err = ioutil.WriteFile(appEnvPath, envSample, 0755)
if err != nil { if err != nil {
return err return err
} }
read, err := ioutil.ReadFile(appEnvPath) logrus.Debugf("copied '%s' to '%s'", envSamplePath, appEnvPath)
if err != nil {
return err
}
newContents := strings.Replace(string(read), recipeName+".example.com", domain, -1)
err = ioutil.WriteFile(appEnvPath, []byte(newContents), 0)
if err != nil {
return err
}
logrus.Debugf("copied & templated %s to %s", envSamplePath, appEnvPath)
return nil return nil
} }
// SanitiseAppName makes a app name usable with Docker by replacing illegal // SanitiseAppName makes a app name usable with Docker by replacing illegal characters
// characters.
func SanitiseAppName(name string) string { func SanitiseAppName(name string) string {
return strings.ReplaceAll(name, ".", "_") return strings.ReplaceAll(name, ".", "_")
} }
// GetAppStatuses queries servers to check the deployment status of given apps. // GetAppStatuses queries servers to check the deployment status of given apps
func GetAppStatuses(apps []App, MachineReadable bool) (map[string]map[string]string, error) { func GetAppStatuses(appFiles AppFiles) (map[string]map[string]string, error) {
statuses := make(map[string]map[string]string) statuses := make(map[string]map[string]string)
var unique []string
servers := make(map[string]struct{}) servers := make(map[string]struct{})
for _, app := range apps { for _, appFile := range appFiles {
if _, ok := servers[app.Server]; !ok { if _, ok := servers[appFile.Server]; !ok {
servers[app.Server] = struct{}{} servers[appFile.Server] = struct{}{}
unique = append(unique, appFile.Server)
} }
} }
var bar *progressbar.ProgressBar bar := formatter.CreateProgressbar(len(servers), "querying remote servers...")
if !MachineReadable {
bar = formatter.CreateProgressbar(len(servers), "querying remote servers...")
}
ch := make(chan stack.StackStatus, len(servers)) ch := make(chan stack.StackStatus, len(servers))
for server := range servers { for server := range servers {
cl, err := client.New(server)
if err != nil {
return statuses, err
}
go func(s string) { go func(s string) {
ch <- stack.GetAllDeployedServices(cl, s) ch <- stack.GetAllDeployedServices(s)
if !MachineReadable {
bar.Add(1) bar.Add(1)
}
}(server) }(server)
} }
for range servers { for range servers {
status := <-ch status := <-ch
if status.Err != nil {
return statuses, status.Err
}
for _, service := range status.Services { for _, service := range status.Services {
result := make(map[string]string) result := make(map[string]string)
name := service.Spec.Labels[convert.LabelNamespace] name := service.Spec.Labels[convert.LabelNamespace]
@ -403,28 +316,13 @@ func GetAppStatuses(apps []App, MachineReadable bool) (map[string]map[string]str
result["status"] = "deployed" result["status"] = "deployed"
} }
labelKey := fmt.Sprintf("coop-cloud.%s.chaos", name) labelKey := fmt.Sprintf("coop-cloud.%s.version", name)
chaos, ok := service.Spec.Labels[labelKey]
if ok {
result["chaos"] = chaos
}
labelKey = fmt.Sprintf("coop-cloud.%s.chaos-version", name)
if chaosVersion, ok := service.Spec.Labels[labelKey]; ok {
result["chaosVersion"] = chaosVersion
}
labelKey = fmt.Sprintf("coop-cloud.%s.autoupdate", name)
if autoUpdate, ok := service.Spec.Labels[labelKey]; ok {
result["autoUpdate"] = autoUpdate
} else {
result["autoUpdate"] = "false"
}
labelKey = fmt.Sprintf("coop-cloud.%s.version", name)
if version, ok := service.Spec.Labels[labelKey]; ok { if version, ok := service.Spec.Labels[labelKey]; ok {
result["version"] = version result["version"] = version
} else { } else {
//FIXME: we only need to check containers with the version label not
// every single container and then skip when we see no label perf gains
// to be had here
continue continue
} }
@ -432,7 +330,7 @@ func GetAppStatuses(apps []App, MachineReadable bool) (map[string]map[string]str
} }
} }
logrus.Debugf("retrieved app statuses: %s", statuses) logrus.Debugf("retrieved app statuses: '%s'", statuses)
return statuses, nil return statuses, nil
} }
@ -444,20 +342,20 @@ func GetAppComposeFiles(recipe string, appEnv AppEnv) ([]string, error) {
if _, ok := appEnv["COMPOSE_FILE"]; !ok { if _, ok := appEnv["COMPOSE_FILE"]; !ok {
logrus.Debug("no COMPOSE_FILE detected, loading compose.yml") logrus.Debug("no COMPOSE_FILE detected, loading compose.yml")
path := fmt.Sprintf("%s/%s/compose.yml", RECIPES_DIR, recipe) path := fmt.Sprintf("%s/%s/compose.yml", APPS_DIR, recipe)
composeFiles = append(composeFiles, path) composeFiles = append(composeFiles, path)
return composeFiles, nil return composeFiles, nil
} }
composeFileEnvVar := appEnv["COMPOSE_FILE"] composeFileEnvVar := appEnv["COMPOSE_FILE"]
envVars := strings.Split(composeFileEnvVar, ":") envVars := strings.Split(composeFileEnvVar, ":")
logrus.Debugf("COMPOSE_FILE detected (%s), loading %s", composeFileEnvVar, strings.Join(envVars, ", ")) logrus.Debugf("COMPOSE_FILE detected ('%s'), loading '%s'", composeFileEnvVar, strings.Join(envVars, ", "))
for _, file := range strings.Split(composeFileEnvVar, ":") { for _, file := range strings.Split(composeFileEnvVar, ":") {
path := fmt.Sprintf("%s/%s/%s", RECIPES_DIR, recipe, file) path := fmt.Sprintf("%s/%s/%s", APPS_DIR, recipe, file)
composeFiles = append(composeFiles, path) composeFiles = append(composeFiles, path)
} }
logrus.Debugf("retrieved %s configs for %s", strings.Join(composeFiles, ", "), recipe) logrus.Debugf("retrieved '%s' configs for '%s'", strings.Join(composeFiles, ", "), recipe)
return composeFiles, nil return composeFiles, nil
} }
@ -471,102 +369,19 @@ func GetAppComposeConfig(recipe string, opts stack.Deploy, appEnv AppEnv) (*comp
return &composetypes.Config{}, err return &composetypes.Config{}, err
} }
logrus.Debugf("retrieved %s for %s", compose.Filename, recipe) logrus.Debugf("retrieved '%s' for '%s'", compose.Filename, recipe)
return compose, nil return compose, nil
} }
// ExposeAllEnv exposes all env variables to the app container // EnsureHostKeysAllServers ensures all configured servers have server SSH host keys validated
func ExposeAllEnv(stackName string, compose *composetypes.Config, appEnv AppEnv) { func EnsureHostKeysAllServers(servers ...string) error {
for _, service := range compose.Services { for _, serverName := range servers {
if service.Name == "app" { logrus.Debugf("ensuring server SSH host key available for %s", serverName)
logrus.Debugf("Add the following environment to the app service config of %s:", stackName) if err := ssh.EnsureHostKey(serverName); err != nil {
for k, v := range appEnv { return err
_, exists := service.Environment[k]
if !exists {
value := v
service.Environment[k] = &value
logrus.Debugf("Add Key: %s Value: %s to %s", k, value, stackName)
}
}
}
} }
} }
// SetRecipeLabel adds the label 'coop-cloud.${STACK_NAME}.recipe=${RECIPE}' to the app container return nil
// to signal which recipe is connected to the deployed app
func SetRecipeLabel(compose *composetypes.Config, stackName string, recipe string) {
for _, service := range compose.Services {
if service.Name == "app" {
logrus.Debugf("set recipe label 'coop-cloud.%s.recipe' to %s for %s", stackName, recipe, stackName)
labelKey := fmt.Sprintf("coop-cloud.%s.recipe", stackName)
service.Deploy.Labels[labelKey] = recipe
}
}
}
// SetChaosLabel adds the label 'coop-cloud.${STACK_NAME}.chaos=true/false' to the app container
// to signal if the app is deployed in chaos mode
func SetChaosLabel(compose *composetypes.Config, stackName string, chaos bool) {
for _, service := range compose.Services {
if service.Name == "app" {
logrus.Debugf("set label 'coop-cloud.%s.chaos' to %v for %s", stackName, chaos, stackName)
labelKey := fmt.Sprintf("coop-cloud.%s.chaos", stackName)
service.Deploy.Labels[labelKey] = strconv.FormatBool(chaos)
}
}
}
// SetChaosVersionLabel adds the label 'coop-cloud.${STACK_NAME}.chaos-version=$(GIT_COMMIT)' to the app container
func SetChaosVersionLabel(compose *composetypes.Config, stackName string, chaosVersion string) {
for _, service := range compose.Services {
if service.Name == "app" {
logrus.Debugf("set label 'coop-cloud.%s.chaos-version' to %v for %s", stackName, chaosVersion, stackName)
labelKey := fmt.Sprintf("coop-cloud.%s.chaos-version", stackName)
service.Deploy.Labels[labelKey] = chaosVersion
}
}
}
// SetUpdateLabel adds env ENABLE_AUTO_UPDATE as label to enable/disable the
// auto update process for this app. The default if this variable is not set is to disable
// the auto update process.
func SetUpdateLabel(compose *composetypes.Config, stackName string, appEnv AppEnv) {
for _, service := range compose.Services {
if service.Name == "app" {
enable_auto_update, exists := appEnv["ENABLE_AUTO_UPDATE"]
if !exists {
enable_auto_update = "false"
}
logrus.Debugf("set label 'coop-cloud.%s.autoupdate' to %s for %s", stackName, enable_auto_update, stackName)
labelKey := fmt.Sprintf("coop-cloud.%s.autoupdate", stackName)
service.Deploy.Labels[labelKey] = enable_auto_update
}
}
}
// GetLabel reads docker labels in the format of "coop-cloud.${STACK_NAME}.${LABEL}" from the local compose files
func GetLabel(compose *composetypes.Config, stackName string, label string) string {
for _, service := range compose.Services {
if service.Name == "app" {
labelKey := fmt.Sprintf("coop-cloud.%s.%s", stackName, label)
logrus.Debugf("get label '%s'", labelKey)
if labelValue, ok := service.Deploy.Labels[labelKey]; ok {
return labelValue
}
}
}
logrus.Debugf("no %s label found for %s", label, stackName)
return ""
}
// GetTimeoutFromLabel reads the timeout value from docker label "coop-cloud.${STACK_NAME}.TIMEOUT" and returns 50 as default value
func GetTimeoutFromLabel(compose *composetypes.Config, stackName string) (int, error) {
var timeout = 50 // Default Timeout
var err error = nil
if timeoutLabel := GetLabel(compose, stackName, "timeout"); timeoutLabel != "" {
logrus.Debugf("timeout label: %s", timeoutLabel)
timeout, err = strconv.Atoi(timeoutLabel)
}
return timeout, err
} }

View File

@ -26,6 +26,7 @@ func TestReadAppEnvFile(t *testing.T) {
} }
func TestGetApp(t *testing.T) { func TestGetApp(t *testing.T) {
// TODO: Test failures as well as successes
app, err := GetApp(expectedAppFiles, appName) app, err := GetApp(expectedAppFiles, appName)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)

View File

@ -14,36 +14,22 @@ import (
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
// getBaseDir retrieves the Abra base directory. var ABRA_DIR = os.ExpandEnv("$HOME/.abra")
func getBaseDir() string { var ABRA_SERVER_FOLDER = path.Join(ABRA_DIR, "servers")
home := os.ExpandEnv("$HOME/.abra") var APPS_JSON = path.Join(ABRA_DIR, "apps.json")
if customAbraDir, exists := os.LookupEnv("ABRA_DIR"); exists && customAbraDir != "" { var APPS_DIR = path.Join(ABRA_DIR, "apps")
home = customAbraDir
}
return home
}
var ABRA_DIR = getBaseDir()
var SERVERS_DIR = path.Join(ABRA_DIR, "servers")
var RECIPES_DIR = path.Join(ABRA_DIR, "recipes")
var VENDOR_DIR = path.Join(ABRA_DIR, "vendor")
var BACKUP_DIR = path.Join(ABRA_DIR, "backups")
var CATALOGUE_DIR = path.Join(ABRA_DIR, "catalogue")
var RECIPES_JSON = path.Join(ABRA_DIR, "catalogue", "recipes.json")
var REPOS_BASE_URL = "https://git.coopcloud.tech/coop-cloud" var REPOS_BASE_URL = "https://git.coopcloud.tech/coop-cloud"
var CATALOGUE_JSON_REPO_NAME = "recipes-catalogue-json"
var SSH_URL_TEMPLATE = "ssh://git@git.coopcloud.tech:2222/coop-cloud/%s.git"
// GetServers retrieves all servers. // GetServers retrieves all servers.
func GetServers() ([]string, error) { func GetServers() ([]string, error) {
var servers []string var servers []string
servers, err := GetAllFoldersInDirectory(SERVERS_DIR) servers, err := getAllFoldersInDirectory(ABRA_SERVER_FOLDER)
if err != nil { if err != nil {
return servers, err return servers, err
} }
logrus.Debugf("retrieved %v servers: %s", len(servers), servers) logrus.Debugf("retrieved '%v' servers: '%s'", len(servers), servers)
return servers, nil return servers, nil
} }
@ -57,26 +43,26 @@ func ReadEnv(filePath string) (AppEnv, error) {
return nil, err return nil, err
} }
logrus.Debugf("read %s from %s", envFile, filePath) logrus.Debugf("read '%s' from '%s'", envFile, filePath)
return envFile, nil return envFile, nil
} }
// ReadServerNames retrieves all server names. // ReadServerNames retrieves all server names.
func ReadServerNames() ([]string, error) { func ReadServerNames() ([]string, error) {
serverNames, err := GetAllFoldersInDirectory(SERVERS_DIR) serverNames, err := getAllFoldersInDirectory(ABRA_SERVER_FOLDER)
if err != nil { if err != nil {
return nil, err return nil, err
} }
logrus.Debugf("read %s from %s", strings.Join(serverNames, ","), SERVERS_DIR) logrus.Debugf("read '%s' from '%s'", strings.Join(serverNames, ","), ABRA_SERVER_FOLDER)
return serverNames, nil return serverNames, nil
} }
// GetAllFilesInDirectory returns filenames of all files in directory // getAllFilesInDirectory returns filenames of all files in directory
func GetAllFilesInDirectory(directory string) ([]fs.FileInfo, error) { func getAllFilesInDirectory(directory string) ([]fs.FileInfo, error) {
var realFiles []fs.FileInfo var realFiles []fs.FileInfo
files, err := ioutil.ReadDir(directory) files, err := ioutil.ReadDir(directory)
@ -94,7 +80,7 @@ func GetAllFilesInDirectory(directory string) ([]fs.FileInfo, error) {
realPath, err := filepath.EvalSymlinks(filePath) realPath, err := filepath.EvalSymlinks(filePath)
if err != nil { if err != nil {
logrus.Warningf("broken symlink in your abra config folders: %s", filePath) logrus.Warningf("broken symlink in your abra config folders: '%s'", filePath)
} else { } else {
realFile, err := os.Stat(realPath) realFile, err := os.Stat(realPath)
if err != nil { if err != nil {
@ -109,8 +95,8 @@ func GetAllFilesInDirectory(directory string) ([]fs.FileInfo, error) {
return realFiles, nil return realFiles, nil
} }
// GetAllFoldersInDirectory returns both folder and symlink paths // getAllFoldersInDirectory returns both folder and symlink paths
func GetAllFoldersInDirectory(directory string) ([]string, error) { func getAllFoldersInDirectory(directory string) ([]string, error) {
var folders []string var folders []string
files, err := ioutil.ReadDir(directory) files, err := ioutil.ReadDir(directory)
@ -118,7 +104,7 @@ func GetAllFoldersInDirectory(directory string) ([]string, error) {
return nil, err return nil, err
} }
if len(files) == 0 { if len(files) == 0 {
return nil, fmt.Errorf("directory is empty: %s", directory) return nil, fmt.Errorf("directory is empty: '%s'", directory)
} }
for _, file := range files { for _, file := range files {
@ -127,7 +113,7 @@ func GetAllFoldersInDirectory(directory string) ([]string, error) {
filePath := path.Join(directory, file.Name()) filePath := path.Join(directory, file.Name())
realDir, err := filepath.EvalSymlinks(filePath) realDir, err := filepath.EvalSymlinks(filePath)
if err != nil { if err != nil {
logrus.Warningf("broken symlink in your abra config folders: %s", filePath) logrus.Warningf("broken symlink in your abra config folders: '%s'", filePath)
} else if stat, err := os.Stat(realDir); err == nil && stat.IsDir() { } else if stat, err := os.Stat(realDir); err == nil && stat.IsDir() {
// path is a directory // path is a directory
folders = append(folders, file.Name()) folders = append(folders, file.Name())
@ -138,6 +124,17 @@ func GetAllFoldersInDirectory(directory string) ([]string, error) {
return folders, nil return folders, nil
} }
// EnsureAbraDirExists checks for the abra config folder and throws error if not
func EnsureAbraDirExists() error {
if _, err := os.Stat(ABRA_DIR); os.IsNotExist(err) {
logrus.Debugf("'%s' does not exist, creating it", ABRA_DIR)
if err := os.Mkdir(ABRA_DIR, 0777); err != nil {
return err
}
}
return nil
}
// ReadAbraShEnvVars reads env vars from an abra.sh recipe file. // ReadAbraShEnvVars reads env vars from an abra.sh recipe file.
func ReadAbraShEnvVars(abraSh string) (map[string]string, error) { func ReadAbraShEnvVars(abraSh string) (map[string]string, error) {
envVars := make(map[string]string) envVars := make(map[string]string)
@ -164,7 +161,7 @@ func ReadAbraShEnvVars(abraSh string) (map[string]string, error) {
} }
} }
logrus.Debugf("read %s from %s", envVars, abraSh) logrus.Debugf("read '%s' from '%s'", envVars, abraSh)
return envVars, nil return envVars, nil
} }

View File

@ -20,12 +20,12 @@ var serverName = "evil.corp"
var expectedAppEnv = AppEnv{ var expectedAppEnv = AppEnv{
"DOMAIN": "ecloud.evil.corp", "DOMAIN": "ecloud.evil.corp",
"RECIPE": "ecloud", "TYPE": "ecloud",
} }
var expectedApp = App{ var expectedApp = App{
Name: appName, Name: appName,
Recipe: expectedAppEnv["RECIPE"], Type: expectedAppEnv["TYPE"],
Domain: expectedAppEnv["DOMAIN"], Domain: expectedAppEnv["DOMAIN"],
Env: expectedAppEnv, Env: expectedAppEnv,
Path: expectedAppFile.Path, Path: expectedAppFile.Path,
@ -44,7 +44,7 @@ var expectedAppFiles = map[string]AppFile{
// var expectedServerNames = []string{"evil.corp"} // var expectedServerNames = []string{"evil.corp"}
func TestGetAllFoldersInDirectory(t *testing.T) { func TestGetAllFoldersInDirectory(t *testing.T) {
folders, err := GetAllFoldersInDirectory(testFolder) folders, err := getAllFoldersInDirectory(testFolder)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -54,7 +54,7 @@ func TestGetAllFoldersInDirectory(t *testing.T) {
} }
func TestGetAllFilesInDirectory(t *testing.T) { func TestGetAllFilesInDirectory(t *testing.T) {
files, err := GetAllFilesInDirectory(testFolder) files, err := getAllFilesInDirectory(testFolder)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -74,11 +74,11 @@ func TestReadEnv(t *testing.T) {
} }
if !reflect.DeepEqual(env, expectedAppEnv) { if !reflect.DeepEqual(env, expectedAppEnv) {
t.Fatalf( t.Fatalf(
"did not get expected application settings. Expected: DOMAIN=%s RECIPE=%s; Got: DOMAIN=%s RECIPE=%s", "did not get expected application settings. Expected: DOMAIN=%s TYPE=%s; Got: DOMAIN=%s TYPE=%s",
expectedAppEnv["DOMAIN"], expectedAppEnv["DOMAIN"],
expectedAppEnv["RECIPE"], expectedAppEnv["TYPE"],
env["DOMAIN"], env["DOMAIN"],
env["RECIPE"], env["TYPE"],
) )
} }
} }

View File

@ -1,70 +0,0 @@
package container
import (
"context"
"fmt"
"strings"
"coopcloud.tech/abra/pkg/formatter"
"github.com/AlecAivazis/survey/v2"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
"github.com/sirupsen/logrus"
)
// GetContainer retrieves a container. If noInput is false and the retrievd
// count of containers does not match 1, then a prompt is presented to let the
// user choose. A count of 0 is handled gracefully.
func GetContainer(c context.Context, cl *client.Client, filters filters.Args, noInput bool) (types.Container, error) {
containerOpts := types.ContainerListOptions{Filters: filters}
containers, err := cl.ContainerList(c, containerOpts)
if err != nil {
return types.Container{}, err
}
if len(containers) == 0 {
filter := filters.Get("name")[0]
return types.Container{}, fmt.Errorf("no containers matching the %v filter found?", filter)
}
if len(containers) != 1 {
var containersRaw []string
for _, container := range containers {
containerName := strings.Join(container.Names, " ")
trimmed := strings.TrimPrefix(containerName, "/")
created := formatter.HumanDuration(container.Created)
containersRaw = append(containersRaw, fmt.Sprintf("%s (created %v)", trimmed, created))
}
if noInput {
err := fmt.Errorf("expected 1 container but found %v: %s", len(containers), strings.Join(containersRaw, " "))
return types.Container{}, err
}
logrus.Warnf("ambiguous container list received, prompting for input")
var response string
prompt := &survey.Select{
Message: "which container are you looking for?",
Options: containersRaw,
}
if err := survey.AskOne(prompt, &response); err != nil {
return types.Container{}, err
}
chosenContainer := strings.TrimSpace(strings.Split(response, " ")[0])
for _, container := range containers {
containerName := strings.TrimSpace(strings.Join(container.Names, " "))
trimmed := strings.TrimPrefix(containerName, "/")
if trimmed == chosenContainer {
return container, nil
}
}
logrus.Panic("failed to match chosen container")
}
return containers[0], nil
}

View File

@ -8,19 +8,22 @@ import (
"github.com/docker/cli/cli/context" "github.com/docker/cli/cli/context"
contextStore "github.com/docker/cli/cli/context/store" contextStore "github.com/docker/cli/cli/context/store"
cliflags "github.com/docker/cli/cli/flags" cliflags "github.com/docker/cli/cli/flags"
"github.com/moby/term"
) )
func NewDefaultDockerContextStore() *command.ContextStoreWithDefault { func NewDefaultDockerContextStore() *command.ContextStoreWithDefault {
_, _, stderr := term.StdStreams()
dockerConfig := dConfig.LoadDefaultConfigFile(stderr)
contextDir := dConfig.ContextStoreDir() contextDir := dConfig.ContextStoreDir()
storeConfig := command.DefaultContextStoreConfig() storeConfig := command.DefaultContextStoreConfig()
store := newContextStore(contextDir, storeConfig) store := newContextStore(contextDir, storeConfig)
opts := &cliflags.ClientOptions{Context: "default"} opts := &cliflags.CommonOptions{Context: "default"}
dockerContextStore := &command.ContextStoreWithDefault{ dockerContextStore := &command.ContextStoreWithDefault{
Store: store, Store: store,
Resolver: func() (*command.DefaultContext, error) { Resolver: func() (*command.DefaultContext, error) {
return command.ResolveDefaultContext(opts, storeConfig) return command.ResolveDefaultContext(opts, dockerConfig, storeConfig, stderr)
}, },
} }

28
pkg/dns/common.go Normal file
View File

@ -0,0 +1,28 @@
package dns
import (
"fmt"
"os"
"github.com/AlecAivazis/survey/v2"
"github.com/sirupsen/logrus"
)
// NewToken constructs a new DNS provider token.
func NewToken(provider, providerTokenEnvVar string) (string, error) {
if token, present := os.LookupEnv(providerTokenEnvVar); present {
return token, nil
}
logrus.Debugf("no %s in environment, asking via stdin", providerTokenEnvVar)
var token string
prompt := &survey.Input{
Message: fmt.Sprintf("%s API token?", provider),
}
if err := survey.AskOne(prompt, &token); err != nil {
return "", err
}
return token, nil
}

View File

@ -1,50 +0,0 @@
package dns
import (
"fmt"
"net"
)
// EnsureIPv4 ensures that an ipv4 address is set for a domain name
func EnsureIPv4(domainName string) (string, error) {
ipv4, err := net.ResolveIPAddr("ip", domainName)
if err != nil {
return "", err
}
return ipv4.String(), nil
}
// EnsureDomainsResolveSameIPv4 ensures that domains resolve to the same ipv4 address
func EnsureDomainsResolveSameIPv4(domainName, server string) (string, error) {
if server == "default" || server == "local" {
return "", nil
}
var ipv4 string
domainIPv4, err := EnsureIPv4(domainName)
if err != nil {
return ipv4, err
}
if domainIPv4 == "" {
return ipv4, fmt.Errorf("cannot resolve ipv4 for %s?", domainName)
}
serverIPv4, err := EnsureIPv4(server)
if err != nil {
return ipv4, err
}
if serverIPv4 == "" {
return ipv4, fmt.Errorf("cannot resolve ipv4 for %s?", server)
}
if domainIPv4 != serverIPv4 {
err := "app domain %s (%s) does not appear to resolve to app server %s (%s)?"
return ipv4, fmt.Errorf(err, domainName, domainIPv4, server, serverIPv4)
}
return ipv4, nil
}

15
pkg/dns/gandi/gandi.go Normal file
View File

@ -0,0 +1,15 @@
package gandi
import (
"coopcloud.tech/abra/pkg/dns"
"github.com/libdns/gandi"
)
// New constructs a new DNs provider.
func New() (gandi.Provider, error) {
token, err := dns.NewToken("Gandi", "GANDI_TOKEN")
if err != nil {
return gandi.Provider{}, err
}
return gandi.Provider{APIToken: token}, nil
}

View File

@ -1,91 +0,0 @@
package formatter
import (
"fmt"
"os"
"strings"
"time"
"github.com/docker/go-units"
// "github.com/olekukonko/tablewriter"
"coopcloud.tech/abra/pkg/jsontable"
"github.com/schollz/progressbar/v3"
"github.com/sirupsen/logrus"
)
func ShortenID(str string) string {
return str[:12]
}
func SmallSHA(hash string) string {
return hash[:8]
}
// RemoveSha remove image sha from a string that are added in some docker outputs
func RemoveSha(str string) string {
return strings.Split(str, "@")[0]
}
// HumanDuration from docker/cli RunningFor() to be accessible outside of the class
func HumanDuration(timestamp int64) string {
date := time.Unix(timestamp, 0)
now := time.Now().UTC()
return units.HumanDuration(now.Sub(date)) + " ago"
}
// CreateTable prepares a table layout for output.
func CreateTable(columns []string) *jsontable.JSONTable {
table := jsontable.NewJSONTable(os.Stdout)
table.SetAutoWrapText(false)
table.SetHeader(columns)
return table
}
// CreateProgressbar generates a progress bar
func CreateProgressbar(length int, title string) *progressbar.ProgressBar {
return progressbar.NewOptions(
length,
progressbar.OptionClearOnFinish(),
progressbar.OptionSetPredictTime(false),
progressbar.OptionShowCount(),
progressbar.OptionFullWidth(),
progressbar.OptionSetDescription(title),
)
}
// StripTagMeta strips front-matter image tag data that we don't need for parsing.
func StripTagMeta(image string) string {
originalImage := image
if strings.Contains(image, "docker.io") {
image = strings.Split(image, "/")[1]
}
if strings.Contains(image, "library") {
image = strings.Split(image, "/")[1]
}
if originalImage != image {
logrus.Debugf("stripped %s to %s for parsing", originalImage, image)
}
return image
}
// ByteCountSI presents a human friendly representation of a byte count. See
// https://yourbasic.org/golang/formatting-byte-size-to-human-readable-format.
func ByteCountSI(b uint64) string {
const unit = 1000
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := uint64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "kMGTPE"[exp])
}

View File

@ -1,100 +0,0 @@
package git
import (
"fmt"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/sirupsen/logrus"
)
// Check if a branch exists in a repo. Use this and not repository.Branch(),
// because the latter does not actually check for existing branches. See
// https://github.com/gogit/gogit/issues/518 for more.
func HasBranch(repository *git.Repository, name string) bool {
var exist bool
if iter, err := repository.Branches(); err == nil {
iterFunc := func(reference *plumbing.Reference) error {
if name == reference.Name().Short() {
exist = true
return nil
}
return nil
}
_ = iter.ForEach(iterFunc)
}
return exist
}
// GetCurrentBranch retrieves the current branch of a repository.
func GetCurrentBranch(repository *git.Repository) (string, error) {
branchRefs, err := repository.Branches()
if err != nil {
return "", err
}
headRef, err := repository.Head()
if err != nil {
return "", err
}
var currentBranchName string
err = branchRefs.ForEach(func(branchRef *plumbing.Reference) error {
if branchRef.Hash() == headRef.Hash() {
currentBranchName = branchRef.Name().String()
return nil
}
return nil
})
if err != nil {
return "", err
}
return currentBranchName, nil
}
// GetDefaultBranch retrieves the default branch of a repository.
func GetDefaultBranch(repo *git.Repository, repoPath string) (plumbing.ReferenceName, error) {
branch := "master"
if !HasBranch(repo, "master") {
if !HasBranch(repo, "main") {
return "", fmt.Errorf("failed to select default branch in %s", repoPath)
}
branch = "main"
}
return plumbing.ReferenceName(fmt.Sprintf("refs/heads/%s", branch)), nil
}
// CheckoutDefaultBranch checks out the default branch of a repository.
func CheckoutDefaultBranch(repo *git.Repository, repoPath string) (plumbing.ReferenceName, error) {
branch, err := GetDefaultBranch(repo, repoPath)
if err != nil {
return plumbing.ReferenceName(""), err
}
worktree, err := repo.Worktree()
if err != nil {
return plumbing.ReferenceName(""), err
}
checkOutOpts := &git.CheckoutOptions{
Create: false,
Force: true,
Branch: branch,
}
if err := worktree.Checkout(checkOutOpts); err != nil {
logrus.Debugf("failed to check out %s in %s", branch, repoPath)
return branch, err
}
logrus.Debugf("successfully checked out %v in %s", branch, repoPath)
return branch, nil
}

View File

@ -7,6 +7,7 @@ import (
"strings" "strings"
"github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
@ -14,37 +15,84 @@ import (
// Clone runs a git clone which accounts for different default branches. // Clone runs a git clone which accounts for different default branches.
func Clone(dir, url string) error { func Clone(dir, url string) error {
if _, err := os.Stat(dir); os.IsNotExist(err) { if _, err := os.Stat(dir); os.IsNotExist(err) {
logrus.Debugf("%s does not exist, attempting to git clone from %s", dir, url) logrus.Debugf("'%s' does not exist, attempting to git clone from '%s'", dir, url)
_, err := git.PlainClone(dir, false, &git.CloneOptions{URL: url, Tags: git.AllTags})
_, err := git.PlainClone(dir, false, &git.CloneOptions{
URL: url,
Tags: git.AllTags,
ReferenceName: plumbing.ReferenceName("refs/heads/master"),
SingleBranch: true,
})
if err != nil { if err != nil {
logrus.Debugf("cloning %s default branch failed, attempting from main branch", url) logrus.Debugf("cloning '%s' default branch failed, attempting from main branch", url)
_, err := git.PlainClone(dir, false, &git.CloneOptions{ _, err := git.PlainClone(dir, false, &git.CloneOptions{
URL: url, URL: url,
Tags: git.AllTags, Tags: git.AllTags,
ReferenceName: plumbing.ReferenceName("refs/heads/main"), ReferenceName: plumbing.ReferenceName("refs/heads/main"),
SingleBranch: true,
}) })
if err != nil { if err != nil {
if strings.Contains(err.Error(), "authentication required") { if strings.Contains(err.Error(), "authentication required") {
name := filepath.Base(dir) name := filepath.Base(dir)
return fmt.Errorf("unable to clone %s, does %s exist?", name, url) return fmt.Errorf("unable to clone %s, does %s exist?", name, url)
} }
return err return err
} }
} }
logrus.Debugf("'%s' has been git cloned successfully", dir)
logrus.Debugf("%s has been git cloned successfully", dir)
} else { } else {
logrus.Debugf("%s already exists", dir) logrus.Debugf("'%s' already exists, doing nothing", dir)
} }
return nil return nil
} }
// EnsureUpToDate ensures that a git repo on disk has the latest changes (git-fetch).
func EnsureUpToDate(dir string) error {
repo, err := git.PlainOpen(dir)
if err != nil {
return err
}
branch := "master"
if _, err := repo.Branch("master"); err != nil {
if _, err := repo.Branch("main"); err != nil {
logrus.Debugf("failed to select branch in '%s'", dir)
return err
}
branch = "main"
}
logrus.Debugf("choosing '%s' as main git branch in '%s'", branch, dir)
worktree, err := repo.Worktree()
if err != nil {
return err
}
refName := fmt.Sprintf("refs/heads/%s", branch)
checkOutOpts := &git.CheckoutOptions{
Create: false,
Force: true,
Branch: plumbing.ReferenceName(refName),
}
if err := worktree.Checkout(checkOutOpts); err != nil {
logrus.Debugf("failed to check out '%s' in '%s'", refName, dir)
return err
}
logrus.Debugf("successfully checked out '%s' in '%s'", branch, dir)
remote, err := repo.Remote("origin")
if err != nil {
return err
}
fetchOpts := &git.FetchOptions{
RemoteName: "origin",
RefSpecs: []config.RefSpec{"refs/heads/*:refs/remotes/origin/*"},
Force: true,
}
if err := remote.Fetch(fetchOpts); err != nil {
if !strings.Contains(err.Error(), "already up-to-date") {
return err
}
}
logrus.Debugf("successfully fetched all changes in '%s'", dir)
return nil
}

View File

@ -1,56 +0,0 @@
package git
import (
"fmt"
"github.com/go-git/go-git/v5"
"github.com/sirupsen/logrus"
)
// Commit runs a git commit
func Commit(repoPath, glob, commitMessage string, dryRun bool) error {
if commitMessage == "" {
return fmt.Errorf("no commit message specified?")
}
commitRepo, err := git.PlainOpen(repoPath)
if err != nil {
return err
}
commitWorktree, err := commitRepo.Worktree()
if err != nil {
return err
}
patterns, err := GetExcludesFiles()
if err != nil {
return err
}
if len(patterns) > 0 {
commitWorktree.Excludes = append(patterns, commitWorktree.Excludes...)
}
if !dryRun {
err = commitWorktree.AddGlob(glob)
if err != nil {
return err
}
logrus.Debugf("staged %s for commit", glob)
} else {
logrus.Debugf("dry run: did not stage %s for commit", glob)
}
if !dryRun {
_, err = commitWorktree.Commit(commitMessage, &git.CommitOptions{})
if err != nil {
return err
}
logrus.Debug("git changes commited")
} else {
logrus.Debug("dry run: no changes commited")
}
return nil
}

View File

@ -1,14 +0,0 @@
package git
import (
"fmt"
"os"
)
// EnsureGitRepo ensures a git repo .git folder exists
func EnsureGitRepo(repoPath string) error {
if _, err := os.Stat(repoPath); os.IsNotExist(err) {
return fmt.Errorf("no .git directory in %s?", repoPath)
}
return nil
}

Some files were not shown because too many files have changed in this diff Show More