Compare commits

..

No commits in common. "main" and "0.4.0-alpha-rc4" have entirely different histories.

121 changed files with 3385 additions and 5048 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.20 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.20 image: golang:1.17
commands: commands:
- make build - make build
depends_on:
- make check
- name: make test - name: make test
image: golang:1.20 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: golang:1.20 image: golang:1.17
environment: environment:
GITEA_TOKEN: GITEA_TOKEN:
from_secret: goreleaser_gitea_token from_secret: goreleaser_gitea_token
@ -47,23 +69,6 @@ steps:
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: {}

2
.gitignore vendored
View File

@ -2,8 +2,6 @@
.e2e.env .e2e.env
.envrc .envrc
.vscode/ .vscode/
/kadabra
abra abra
dist/ dist/
tests/integration/.abra/catalogue
vendor/ vendor/

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,13 +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 :heart:
- 3wordchant
- cassowary
- decentral1se
- frando
- kawaiipunk
- knoflook
- moritz
- roxxers

View File

@ -1,17 +0,0 @@
FROM golang:1.20-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,13 +1,11 @@
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: format check static build test
run: run:
@go run -ldflags=$(LDFLAGS) $(ABRA) @go run -ldflags=$(LDFLAGS) $(ABRA)
@ -16,22 +14,22 @@ install:
@go install -ldflags=$(LDFLAGS) $(ABRA) @go install -ldflags=$(LDFLAGS) $(ABRA)
build-dev: build-dev:
@go build -v -ldflags=$(LDFLAGS) $(ABRA) @go build -ldflags=$(LDFLAGS) $(ABRA)
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
@ -45,3 +43,15 @@ loc-author:
sort -f | \ sort -f | \
uniq -ic | \ uniq -ic | \
sort -n sort -n
int-core:
@docker run \
-v $$(pwd):/src \
--env-file .e2e.env \
debian:bullseye-slim \
sh -c "\
apt update && apt install -y wget curl git; echo ""; echo ""; \
git config --global user.email 'e2e@coopcloud.tech'; \
git config --global user.name 'e2e'; \
cd /src/tests/integration && bash core.sh -- --dev \
"

View File

@ -1,12 +1,75 @@
# `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)
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 :heart: ## Quick install
Please see [docs.coopcloud.tech/abra](https://docs.coopcloud.tech/abra) for help on install, upgrade, hacking, troubleshooting & more! ```bash
curl https://install.abra.autonomic.zone | bash
```
Or using the latest release candidate (extra experimental!):
```bash
curl https://install.abra.autonomic.zone | bash -s -- --rc
```
Source for this script is in [scripts/installer/installer](./scripts/installer/installer).
## Hacking
### 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,40 @@
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
Name: "app", var AppCommand = &cli.Command{
Aliases: []string{"a"}, Name: "app",
Usage: "Manage apps", Usage: "Manage apps",
ArgsUsage: "<domain>", Aliases: []string{"a"},
Description: "Functionality for managing the life cycle of your apps", ArgsUsage: "<app>",
Subcommands: []cli.Command{ Description: `
appBackupCommand, This command provides all the functionality you need to manage the life cycle
appCheckCommand, of your apps. From initial deployment, day-2 operations (e.g. backup/restore)
appCmdCommand, to scaling apps up and spinning them down.
appConfigCommand, `,
appCpCommand, Subcommands: []*cli.Command{
appDeployCommand,
appErrorsCommand,
appListCommand,
appLogsCommand,
appNewCommand, appNewCommand,
appPsCommand, appConfigCommand,
appRemoveCommand,
appRestartCommand, appRestartCommand,
appRestoreCommand, appDeployCommand,
appRollbackCommand,
appRunCommand,
appSecretCommand,
appServicesCommand,
appUndeployCommand,
appUpgradeCommand, appUpgradeCommand,
appVersionCommand, appUndeployCommand,
appBackupCommand,
appRestoreCommand,
appRemoveCommand,
appCheckCommand,
appListCommand,
appPsCommand,
appLogsCommand,
appCpCommand,
appRunCommand,
appRollbackCommand,
appSecretCommand,
appVolumeCommand, appVolumeCommand,
appVersionCommand,
appErrorsCommand,
}, },
} }

View File

@ -1,399 +1,77 @@
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/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"
"coopcloud.tech/abra/pkg/runtime"
"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/docker/docker/pkg/system"
"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,
},
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)
conf := runtime.New()
cl, err := client.New(app.Server) if c.Args().Get(1) != "" && backupAllServices {
if err != nil { internal.ShowSubcommandHelpAndError(c, errors.New("cannot use '<service>' and '--all' together"))
}
abraSh := path.Join(config.RECIPES_DIR, 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)
} }
recipe, err := recipe.Get(app.Recipe, conf) sourceCmd := fmt.Sprintf("source %s", abraSh)
execCmd := "abra_backup"
if !backupAllServices {
serviceName := c.Args().Get(1)
if serviceName == "" {
internal.ShowSubcommandHelpAndError(c, errors.New("no service(s) target provided"))
}
execCmd = fmt.Sprintf("abra_backup_%s", serviceName)
}
bytes, err := ioutil.ReadFile(abraSh)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if !strings.Contains(string(bytes), execCmd) {
backupConfigs := make(map[string]backupConfig) logrus.Fatalf("%s doesn't have a %s function", app.Type, execCmd)
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
}
}
} }
serviceName := c.Args().Get(1) sourceAndExec := fmt.Sprintf("%s; %s", sourceCmd, execCmd)
if serviceName != "" { cmd := exec.Command("bash", "-c", sourceAndExec)
backupConfig, ok := backupConfigs[serviceName] if err := internal.RunCmd(cmd); err != nil {
if !ok { logrus.Fatal(err)
logrus.Fatalf("no backup config for %s? does %s exist?", serviceName, serviceName)
}
logrus.Infof("running backup for the %s service", serviceName)
if err := runBackup(cl, app, serviceName, backupConfig); err != nil {
logrus.Fatal(err)
}
} else {
if len(backupConfigs) == 0 {
logrus.Fatalf("no backup configs discovered for %s?", app.Name)
}
for serviceName, backupConfig := range backupConfigs {
logrus.Infof("running backup for the %s service", serviceName)
if err := runBackup(cl, app, serviceName, backupConfig); err != nil {
logrus.Fatal(err)
}
}
} }
return nil return nil
}, },
} BashComplete: autocomplete.AppNameComplete,
// 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 {
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 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 := system.TempFileSequential(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
}
for {
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

@ -9,22 +9,18 @@ import (
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"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 app is configured correctly",
ArgsUsage: "<domain>", Aliases: []string{"c"},
Flags: []cli.Flag{ ArgsUsage: "<service>",
internal.DebugFlag,
},
Before: internal.SubCommandBefore,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
envSamplePath := path.Join(config.RECIPES_DIR, app.Recipe, ".env.sample") envSamplePath := path.Join(config.RECIPES_DIR, app.Type, ".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)

View File

@ -1,245 +0,0 @@
package app
import (
"context"
"errors"
"fmt"
"io/ioutil"
"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"
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"
"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,
},
BashComplete: autocomplete.AppNameComplete,
Before: internal.SubCommandBefore,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
cl, err := client.New(app.Server)
if 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 {
cmdName := c.Args().Get(1)
if err := 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 {
targetServiceName := c.Args().Get(1)
cmdName := c.Args().Get(2)
if err := 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")
}
if err := 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
}
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
}
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, true)
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 internal.RemoteUser != "" {
logrus.Debugf("running command with user %s", internal.RemoteUser)
execCreateOpts.User = internal.RemoteUser
}
execCreateOpts.Cmd = cmd
execCreateOpts.Tty = true
if err := container.RunExec(dcli, cl, targetContainer.ID, &execCreateOpts); err != nil {
return err
}
return nil
}

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

@ -10,18 +10,13 @@ import (
"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,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
appName := c.Args().First() appName := c.Args().First()

View File

@ -1,7 +1,6 @@
package app package app
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"strings" "strings"
@ -14,41 +13,31 @@ import (
"coopcloud.tech/abra/pkg/formatter" "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 running app service",
Description: ` Description: `
Copy files to and from any app service file system. This command supports copying files to and from any app service file system.
If you want to copy a myfile.txt to the root of the app service: If you want to copy a myfile.txt to the root of the app service:
abra app cp <domain> myfile.txt app:/ abra app cp <app> myfile.txt app:/
And if you want to copy that file back to your current working directory locally: And if you want to copy that file back to your current working directory locally:
abra app cp <domain> app:/myfile.txt . abra app cp <app> app:/myfile.txt .
`, `,
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)
if err != nil {
logrus.Fatal(err)
}
src := c.Args().Get(1) src := c.Args().Get(1)
dst := c.Args().Get(2) dst := c.Args().Get(2)
if src == "" { if src == "" {
@ -94,28 +83,42 @@ And if you want to copy that file back to your current working directory locally
logrus.Fatalf("%s does not exist locally?", dstPath) logrus.Fatalf("%s does not exist locally?", dstPath)
} }
} }
err := configureAndCp(c, app, srcPath, dstPath, service, isToContainer)
if err := configureAndCp(c, cl, app, srcPath, dstPath, service, isToContainer); err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
return nil return nil
}, },
BashComplete: autocomplete.AppNameComplete, BashComplete: autocomplete.AppNameComplete,
} }
func configureAndCp( func configureAndCp(
c *cli.Context, c *cli.Context,
cl *dockerClient.Client,
app config.App, app config.App,
srcPath string, srcPath string,
dstPath string, dstPath string,
service string, service string,
isToContainer bool) error { isToContainer bool) error {
filters := filters.NewArgs() appFiles, err := config.LoadAppFiles("")
filters.Add("name", fmt.Sprintf("^%s_%s", app.StackName(), service)) if err != nil {
logrus.Fatal(err)
}
container, err := container.GetContainer(context.Background(), cl, filters, internal.NoInput) appEnv, err := config.GetApp(appFiles, app.Name)
if err != nil {
logrus.Fatal(err)
}
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
}
filters := filters.NewArgs()
filters.Add("name", fmt.Sprintf("%s_%s", appEnv.StackName(), service))
container, err := container.GetContainer(c.Context, cl, filters, true)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -134,11 +137,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)
} }
@ -148,6 +151,5 @@ func configureAndCp(
logrus.Fatal(err) logrus.Fatal(err)
} }
} }
return nil return nil
} }

View File

@ -3,26 +3,23 @@ package app
import ( import (
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"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>",
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.ForceFlag, internal.ForceFlag,
internal.ChaosFlag, internal.ChaosFlag,
internal.NoDomainChecksFlag, internal.NoDomainChecksFlag,
internal.DontWaitConvergeFlag, internal.DontWaitConvergeFlag,
}, },
Before: internal.SubCommandBefore,
Description: ` Description: `
Deploy an app. It does not support incrementing the version of a deployed app, This command deploys an app. It does not support incrementing the version of a
for this you need to look at the "abra app upgrade <domain>" command. 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.

View File

@ -1,8 +1,6 @@
package app package app
import ( import (
"context"
"fmt"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -12,21 +10,19 @@ import (
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/runtime"
stack "coopcloud.tech/abra/pkg/upstream/stack" stack "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 appErrorsCommand = cli.Command{ var appErrorsCommand = &cli.Command{
Name: "errors", Name: "errors",
Usage: "List errors for a deployed app", Usage: "List errors for a deployed app",
ArgsUsage: "<domain>",
Description: ` Description: `
List errors for a deployed app. This command lists errors for a deployed app.
This is a best-effort implementation and an attempt to gather a number of tips 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 & tricks for finding errors together into one convenient command. When an app
@ -43,27 +39,23 @@ Got any more ideas? Please let us know:
https://git.coopcloud.tech/coop-cloud/organising/issues/new/choose https://git.coopcloud.tech/coop-cloud/organising/issues/new/choose
This command is best accompanied by "abra app logs <domain>" which may reveal This command is best accompanied by "abra app logs <app>" which may reveal
further information which can help you debug the cause of an app failure via further information which can help you debug the cause of an app failure via
the logs. the logs.
`, `,
Aliases: []string{"e"}, Aliases: []string{"e"},
Flags: []cli.Flag{ Flags: []cli.Flag{internal.WatchFlag},
internal.DebugFlag,
internal.WatchFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.AppNameComplete, BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
conf := runtime.New()
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, app.StackName()) isDeployed, _, err := stack.IsDeployed(c.Context, cl, app.StackName())
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -73,14 +65,14 @@ the logs.
} }
if !internal.Watch { if !internal.Watch {
if err := checkErrors(c, cl, app, conf); err != nil { if err := checkErrors(c, cl, app); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
return nil return nil
} }
for { for {
if err := checkErrors(c, cl, app, conf); err != nil { if err := checkErrors(c, cl, app); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
@ -90,17 +82,16 @@ the logs.
}, },
} }
func checkErrors(c *cli.Context, cl *dockerClient.Client, app config.App, conf *runtime.Config) error { func checkErrors(c *cli.Context, cl *dockerClient.Client, app config.App) error {
recipe, err := recipe.Get(app.Recipe, conf) recipe, err := recipe.Get(app.Type)
if err != nil { if err != nil {
return err return err
} }
for _, service := range recipe.Config.Services { for _, service := range recipe.Config.Services {
filters := filters.NewArgs() filters := filters.NewArgs()
filters.Add("name", fmt.Sprintf("^%s_%s", app.StackName(), service.Name)) filters.Add("name", service.Name)
containers, err := cl.ContainerList(c.Context, types.ContainerListOptions{Filters: filters})
containers, err := cl.ContainerList(context.Background(), types.ContainerListOptions{Filters: filters})
if err != nil { if err != nil {
return err return err
} }
@ -111,7 +102,7 @@ func checkErrors(c *cli.Context, cl *dockerClient.Client, app config.App, conf *
} }
container := containers[0] container := containers[0]
containerState, err := cl.ContainerInspect(context.Background(), container.ID) containerState, err := cl.ContainerInspect(c.Context, container.ID)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }

View File

@ -1,7 +1,6 @@
package app package app
import ( import (
"encoding/json"
"fmt" "fmt"
"sort" "sort"
"strings" "strings"
@ -10,85 +9,87 @@ import (
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/ssh"
"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 { type appStatus struct {
Server string `json:"server"` server string
Recipe string `json:"recipe"` recipe string
AppName string `json:"appName"` appName string
Domain string `json:"domain"` domain string
Status string `json:"status"` status string
Version string `json:"version"` version string
Upgrade string `json:"upgrade"` upgrade string
} }
type serverStatus struct { type serverStatus struct {
Apps []appStatus `json:"apps"` apps []appStatus
AppCount int `json:"appCount"` appCount int
VersionCount int `json:"versionCount"` versionCount int
UnversionedCount int `json:"unversionedCount"` unversionedCount int
LatestCount int `json:"latestCount"` latestCount int
UpgradeCount int `json:"upgradeCount"` upgradeCount int
} }
var appListCommand = cli.Command{ 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,
}, },
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.ByServerAndRecipe(apps)) sort.Sort(config.ByServerAndType(apps))
statuses := make(map[string]map[string]string) statuses := make(map[string]map[string]string)
var catl recipe.RecipeCatalogue var catl recipe.RecipeCatalogue
@ -96,15 +97,19 @@ can take some time.
alreadySeen := make(map[string]bool) alreadySeen := make(map[string]bool)
for _, app := range apps { for _, app := range apps {
if _, ok := alreadySeen[app.Server]; !ok { if _, ok := alreadySeen[app.Server]; !ok {
if err := ssh.EnsureHostKey(app.Server); err != nil {
logrus.Fatal(fmt.Sprintf(internal.SSHFailMsg, app.Server))
}
alreadySeen[app.Server] = true alreadySeen[app.Server] = true
} }
} }
statuses, err = config.GetAppStatuses(apps, internal.MachineReadable) statuses, err = config.GetAppStatuses(appFiles)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
var err error
catl, err = recipe.ReadRecipeCatalogue() catl, err = recipe.ReadRecipeCatalogue()
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
@ -119,20 +124,20 @@ can take some time.
var ok bool var ok bool
if stats, ok = allStats[app.Server]; !ok { if stats, ok = allStats[app.Server]; !ok {
stats = serverStatus{} stats = serverStatus{}
if recipeFilter == "" { if appType == "" {
// count server, no filtering // count server, no filtering
totalServersCount++ totalServersCount++
} }
} }
if app.Recipe == recipeFilter || recipeFilter == "" { if app.Type == appType || appType == "" {
if recipeFilter != "" { if appType != "" {
// only count server if matches filter // only count server if matches filter
totalServersCount++ totalServersCount++
} }
appStats := appStatus{} appStats := appStatus{}
stats.AppCount++ stats.appCount++
totalAppsCount++ totalAppsCount++
if status { if status {
@ -140,24 +145,22 @@ can take some time.
version := "unknown" version := "unknown"
if statusMeta, ok := statuses[app.StackName()]; ok { 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 statusMeta["status"] != "" { if statusMeta["status"] != "" {
status = statusMeta["status"] status = statusMeta["status"]
} }
stats.VersionCount++ stats.versionCount++
} else { } else {
stats.UnversionedCount++ stats.unversionedCount++
} }
appStats.Status = status appStats.status = status
appStats.Version = version appStats.version = version
var newUpdates []string var newUpdates []string
if version != "unknown" { if version != "unknown" {
updates, err := recipe.GetRecipeCatalogueVersions(app.Recipe, catl) updates, err := recipe.GetRecipeCatalogueVersions(app.Type, catl)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -181,38 +184,29 @@ can take some time.
if len(newUpdates) == 0 { if len(newUpdates) == 0 {
if version == "unknown" { if version == "unknown" {
appStats.Upgrade = "unknown" appStats.upgrade = "unknown"
} else { } else {
appStats.Upgrade = "latest" appStats.upgrade = "latest"
stats.LatestCount++ stats.latestCount++
} }
} else { } else {
newUpdates = internal.ReverseStringList(newUpdates) newUpdates = internal.ReverseStringList(newUpdates)
appStats.Upgrade = strings.Join(newUpdates, "\n") appStats.upgrade = strings.Join(newUpdates, "\n")
stats.UpgradeCount++ stats.upgradeCount++
} }
} }
appStats.Server = app.Server appStats.server = app.Server
appStats.Recipe = app.Recipe appStats.recipe = app.Type
appStats.AppName = app.Name appStats.appName = app.Name
appStats.Domain = app.Domain appStats.domain = app.Domain
stats.Apps = append(stats.Apps, appStats) stats.apps = append(stats.apps, appStats)
} }
allStats[app.Server] = stats 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) alreadySeen := make(map[string]bool)
for _, app := range apps { for _, app := range apps {
if _, ok := alreadySeen[app.Server]; ok { if _, ok := alreadySeen[app.Server]; ok {
@ -221,17 +215,17 @@ can take some time.
serverStat := allStats[app.Server] serverStat := allStats[app.Server]
tableCol := []string{"recipe", "domain"} tableCol := []string{"recipe", "domain", "app name"}
if status { if status {
tableCol = append(tableCol, []string{"status", "version", "upgrade"}...) tableCol = append(tableCol, []string{"status", "version", "upgrade"}...)
} }
table := formatter.CreateTable(tableCol) table := formatter.CreateTable(tableCol)
for _, appStat := range serverStat.Apps { for _, appStat := range serverStat.apps {
tableRow := []string{appStat.Recipe, appStat.Domain} tableRow := []string{appStat.recipe, appStat.domain, appStat.appName}
if status { if status {
tableRow = append(tableRow, []string{appStat.Status, appStat.Version, appStat.Upgrade}...) tableRow = append(tableRow, []string{appStat.status, appStat.version, appStat.upgrade}...)
} }
table.Append(tableRow) table.Append(tableRow)
} }
@ -243,14 +237,14 @@ can take some time.
fmt.Println(fmt.Sprintf( fmt.Println(fmt.Sprintf(
"server: %s | total apps: %v | versioned: %v | unversioned: %v | latest: %v | upgrade: %v", "server: %s | total apps: %v | versioned: %v | unversioned: %v | latest: %v | upgrade: %v",
app.Server, app.Server,
serverStat.AppCount, serverStat.appCount,
serverStat.VersionCount, serverStat.versionCount,
serverStat.UnversionedCount, serverStat.unversionedCount,
serverStat.LatestCount, serverStat.latestCount,
serverStat.UpgradeCount, serverStat.upgradeCount,
)) ))
} else { } else {
fmt.Println(fmt.Sprintf("server: %s | total apps: %v", app.Server, serverStat.AppCount)) fmt.Println(fmt.Sprintf("server: %s | total apps: %v", app.Server, serverStat.appCount))
} }
} }

View File

@ -1,7 +1,6 @@
package app package app
import ( import (
"context"
"fmt" "fmt"
"io" "io"
"os" "os"
@ -11,35 +10,29 @@ import (
"coopcloud.tech/abra/pkg/autocomplete" "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/runtime"
"coopcloud.tech/abra/pkg/service" "coopcloud.tech/abra/pkg/service"
"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{ var logOpts = types.ContainerLogsOptions{
Details: false,
Follow: true,
ShowStderr: true, ShowStderr: true,
ShowStdout: true, ShowStdout: true,
Since: "",
Until: "",
Timestamps: true,
Follow: true,
Tail: "20", Tail: "20",
Details: false, Timestamps: true,
} }
// 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)
} }
@ -52,7 +45,7 @@ func stackLogs(c *cli.Context, app config.App, client *dockerClient.Client) {
logOpts.ShowStdout = false logOpts.ShowStdout = false
} }
logs, err := client.ServiceLogs(context.Background(), s, logOpts) logs, err := client.ServiceLogs(c.Context, s, logOpts)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -70,32 +63,27 @@ func stackLogs(c *cli.Context, app config.App, client *dockerClient.Client) {
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{ Flags: []cli.Flag{
internal.StdErrOnlyFlag, internal.StdErrOnlyFlag,
internal.SinceLogsFlag,
internal.DebugFlag,
}, },
Before: internal.SubCommandBefore,
BashComplete: autocomplete.AppNameComplete, BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c, runtime.WithEnsureRecipeExists(false)) app := internal.ValidateApp(c)
cl, err := client.New(app.Server) cl, err := client.New(app.Server)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
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.Debugf("tailing logs for all %s services", app.Type)
stackLogs(c, app, cl) stackLogs(c, app.StackName(), cl)
} else { } else {
logrus.Debugf("tailing logs for %s", serviceName) logrus.Debugf("tailing logs for %s", serviceName)
if err := tailServiceLogs(c, cl, app, serviceName); err != nil { if err := tailServiceLogs(c, cl, app, serviceName); err != nil {
@ -110,8 +98,7 @@ var appLogsCommand = cli.Command{
func tailServiceLogs(c *cli.Context, cl *dockerClient.Client, app config.App, serviceName string) error { 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", fmt.Sprintf("%s_%s", app.StackName(), serviceName))
chosenService, err := service.GetService(c.Context, cl, filters, internal.NoInput)
chosenService, err := service.GetService(context.Background(), cl, filters, internal.NoInput)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -120,7 +107,7 @@ func tailServiceLogs(c *cli.Context, cl *dockerClient.Client, app config.App, se
logOpts.ShowStdout = false logOpts.ShowStdout = false
} }
logs, err := cl.ServiceLogs(context.Background(), chosenService.ID, logOpts) logs, err := cl.ServiceLogs(c.Context, chosenService.ID, logOpts)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }

View File

@ -3,15 +3,15 @@ package app
import ( import (
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"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".
@ -26,21 +26,19 @@ 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,
}, },
Before: internal.SubCommandBefore, ArgsUsage: "<recipe>",
ArgsUsage: "[<recipe>]",
Action: internal.NewAction, Action: internal.NewAction,
BashComplete: autocomplete.RecipeNameComplete, BashComplete: autocomplete.RecipeNameComplete,
} }

View File

@ -1,7 +1,6 @@
package app package app
import ( import (
"context"
"strings" "strings"
"time" "time"
@ -15,22 +14,20 @@ import (
"github.com/buger/goterm" "github.com/buger/goterm"
dockerFormatter "github.com/docker/cli/cli/command/formatter" dockerFormatter "github.com/docker/cli/cli/command/formatter"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"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 appPsCommand = cli.Command{ var appPsCommand = &cli.Command{
Name: "ps", Name: "ps",
Aliases: []string{"p"},
Usage: "Check app status", Usage: "Check app status",
ArgsUsage: "<domain>", Description: "This command shows a more detailed status output of a specific deployed app.",
Description: "Show a more detailed status output of a specific deployed app", Aliases: []string{"p"},
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.WatchFlag, internal.WatchFlag,
internal.DebugFlag,
}, },
Before: internal.SubCommandBefore,
BashComplete: autocomplete.AppNameComplete, BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
@ -40,7 +37,7 @@ var appPsCommand = cli.Command{
logrus.Fatal(err) logrus.Fatal(err)
} }
isDeployed, _, err := stack.IsDeployed(context.Background(), cl, app.StackName()) isDeployed, _, err := stack.IsDeployed(c.Context, cl, app.StackName())
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -66,12 +63,10 @@ var appPsCommand = cli.Command{
// showPSOutput renders ps output. // showPSOutput renders ps output.
func showPSOutput(c *cli.Context, app config.App, cl *dockerClient.Client) { func showPSOutput(c *cli.Context, app config.App, cl *dockerClient.Client) {
filters, err := app.Filters(true, true) filters := filters.NewArgs()
if err != nil { filters.Add("name", app.StackName())
logrus.Fatal(err)
}
containers, err := cl.ContainerList(context.Background(), types.ContainerListOptions{Filters: filters}) containers, err := cl.ContainerList(c.Context, types.ContainerListOptions{Filters: filters})
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }

View File

@ -1,7 +1,6 @@
package app package app
import ( import (
"context"
"fmt" "fmt"
"os" "os"
@ -11,47 +10,37 @@ import (
stack "coopcloud.tech/abra/pkg/upstream/stack" stack "coopcloud.tech/abra/pkg/upstream/stack"
"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/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
Name: "remove", var Volumes bool
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, // VolumesFlag is used to specify if volumes should be deleted when deleting an app
volumes and the local app env file will be deleted. var VolumesFlag = &cli.BoolFlag{
Name: "volumes",
Value: false,
Destination: &Volumes,
}
Only run this command when you are sure you want to completely remove the app var appRemoveCommand = &cli.Command{
and all associated app data. This is a destructive action, Be Careful! Name: "remove",
Usage: "Remove an already undeployed app",
If you would like to delete specific volumes or secrets, please use removal Aliases: []string{"rm"},
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,
}, },
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 remove %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)
} }
@ -65,20 +54,17 @@ flag.
logrus.Fatal(err) logrus.Fatal(err)
} }
isDeployed, _, err := stack.IsDeployed(context.Background(), cl, app.StackName()) isDeployed, _, err := stack.IsDeployed(c.Context, cl, app.StackName())
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if isDeployed { if isDeployed {
logrus.Fatalf("%s is still deployed. Run \"abra app undeploy %s\"", app.Name, app.Name) logrus.Fatalf("%s is still deployed. Run \"abra app undeploy %s \" or pass --force", app.Name, app.Name)
} }
fs, err := app.Filters(false, false) fs := filters.NewArgs()
if err != nil { fs.Add("name", app.StackName())
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)
} }
@ -92,8 +78,23 @@ 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?",
Help: "'x' indicates selected, enter / return to confirm, ctrl-c to exit, vim mode is enabled",
VimMode: true,
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)
} }
@ -103,12 +104,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)
}
volumeListOKBody, err := cl.VolumeList(context.Background(), fs)
volumeList := volumeListOKBody.Volumes volumeList := volumeListOKBody.Volumes
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
@ -120,24 +116,43 @@ flag.
} }
if len(vols) > 0 { if len(vols) > 0 {
var removeVols []string if Volumes {
for _, vol := range removeVols { var removeVols []string
err := cl.VolumeRemove(context.Background(), vol, internal.Force) // last argument is for force removing if !internal.Force {
if err != nil { volumesPrompt := &survey.MultiSelect{
logrus.Fatal(err) 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: vols,
Default: vols,
}
if err := survey.AskOne(volumesPrompt, &removeVols); err != nil {
logrus.Fatal(err)
}
} }
logrus.Info(fmt.Sprintf("volume %s removed", vol)) for _, vol := range removeVols {
err := cl.VolumeRemove(c.Context, vol, internal.Force) // last argument is for force removing
if err != nil {
logrus.Fatal(err)
}
logrus.Info(fmt.Sprintf("volume %s removed", vol))
}
} else {
logrus.Info("no volumes were removed")
} }
} else { } else {
logrus.Info("no volumes to remove") if Volumes {
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: autocomplete.AppNameComplete,
} }

View File

@ -1,7 +1,6 @@
package app package app
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
@ -11,18 +10,14 @@ import (
upstream "coopcloud.tech/abra/pkg/upstream/service" upstream "coopcloud.tech/abra/pkg/upstream/service"
stack "coopcloud.tech/abra/pkg/upstream/stack" stack "coopcloud.tech/abra/pkg/upstream/stack"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var appRestartCommand = cli.Command{ var appRestartCommand = &cli.Command{
Name: "restart", Name: "restart",
Aliases: []string{"re"}, Usage: "Restart an app",
Usage: "Restart an app", Aliases: []string{"re"},
ArgsUsage: "<domain>", ArgsUsage: "<service>",
Flags: []cli.Flag{
internal.DebugFlag,
},
Before: internal.SubCommandBefore,
Description: `This command restarts a service within a deployed app.`, Description: `This command restarts a service within a deployed app.`,
BashComplete: autocomplete.AppNameComplete, BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
@ -42,22 +37,22 @@ var appRestartCommand = cli.Command{
serviceName := fmt.Sprintf("%s_%s", app.StackName(), serviceNameShort) serviceName := fmt.Sprintf("%s_%s", app.StackName(), serviceNameShort)
logrus.Debugf("attempting to scale %s to 0 (restart logic)", serviceName) logrus.Debugf("attempting to scale %s to 0 (restart logic)", serviceName)
if err := upstream.RunServiceScale(context.Background(), cl, serviceName, 0); err != nil { if err := upstream.RunServiceScale(c.Context, cl, serviceName, 0); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if err := stack.WaitOnService(context.Background(), cl, serviceName, app.Name); err != nil { if err := stack.WaitOnService(c.Context, cl, serviceName, app.Name); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Debugf("%s has been scaled to 0 (restart logic)", serviceName) logrus.Debugf("%s has been scaled to 0 (restart logic)", serviceName)
logrus.Debugf("attempting to scale %s to 1 (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 { if err := upstream.RunServiceScale(c.Context, cl, serviceName, 1); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if err := stack.WaitOnService(context.Background(), cl, serviceName, app.Name); err != nil { if err := stack.WaitOnService(c.Context, cl, serviceName, app.Name); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }

View File

@ -1,205 +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"
"coopcloud.tech/abra/pkg/runtime"
"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",
Usage: "Restore an app from a backup",
Aliases: []string{"rs"}, Aliases: []string{"rs"},
Usage: "Run app restore", Flags: []cli.Flag{restoreAllServicesFlag},
ArgsUsage: "<domain> <service> <file>", ArgsUsage: "<service> [<backup file>]",
Flags: []cli.Flag{
internal.DebugFlag,
},
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.
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)
conf := runtime.New()
cl, err := client.New(app.Server) if c.Args().Len() > 1 && restoreAllServices {
if err != nil { internal.ShowSubcommandHelpAndError(c, errors.New("cannot use <service>/<backup file> and '--all' together"))
logrus.Fatal(err)
} }
serviceName := c.Args().Get(1) abraSh := path.Join(config.RECIPES_DIR, app.Type, "abra.sh")
if serviceName == "" { if _, err := os.Stat(abraSh); err != nil {
internal.ShowSubcommandHelpAndError(c, errors.New("missing <service>?"))
}
backupPath := c.Args().Get(2)
if backupPath == "" {
internal.ShowSubcommandHelpAndError(c, errors.New("missing <file>?"))
}
if _, err := os.Stat(backupPath); err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
logrus.Fatalf("%s doesn't exist?", backupPath) logrus.Fatalf("%s does not exist?", abraSh)
} }
}
recipe, err := recipe.Get(app.Recipe, conf)
if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
restoreConfigs := make(map[string]restoreConfig) sourceCmd := fmt.Sprintf("source %s", abraSh)
for _, service := range recipe.Config.Services { execCmd := "abra_restore"
if restoreEnabled, ok := service.Deploy.Labels["backupbot.restore"]; ok { if !restoreAllServices {
if restoreEnabled == "true" { serviceName := c.Args().Get(1)
fullServiceName := fmt.Sprintf("%s_%s", app.StackName(), service.Name) if serviceName == "" {
rsConfig := restoreConfig{} internal.ShowSubcommandHelpAndError(c, errors.New("no service(s) target provided"))
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
}
} }
execCmd = fmt.Sprintf("abra_restore_%s", serviceName)
} }
rsConfig, ok := restoreConfigs[serviceName] bytes, err := ioutil.ReadFile(abraSh)
if !ok { if err != nil {
rsConfig = restoreConfig{} 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
}
// we use absolute paths so tar knows what to do. it will restore files
// according to the paths set in the compresed 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,14 +1,12 @@
package app package app
import ( import (
"context"
"fmt" "fmt"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/lint" "coopcloud.tech/abra/pkg/lint"
"coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/runtime"
stack "coopcloud.tech/abra/pkg/upstream/stack" stack "coopcloud.tech/abra/pkg/upstream/stack"
"coopcloud.tech/tagcmp" "coopcloud.tech/tagcmp"
@ -16,23 +14,19 @@ 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>", Aliases: []string{"rl"},
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.DontWaitConvergeFlag,
}, },
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,7 +34,7 @@ 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
@ -50,15 +44,12 @@ recipes.
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()
conf := runtime.New()
if !internal.Chaos { if err := recipe.EnsureUpToDate(app.Type); err != nil {
if err := recipe.EnsureUpToDate(app.Recipe); err != nil { logrus.Fatal(err)
logrus.Fatal(err)
}
} }
r, err := recipe.Get(app.Recipe, conf) r, err := recipe.Get(app.Type)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -74,7 +65,7 @@ recipes.
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)
} }
@ -88,13 +79,13 @@ recipes.
logrus.Fatal(err) logrus.Fatal(err)
} }
versions, err := recipe.GetRecipeCatalogueVersions(app.Recipe, catl) versions, err := recipe.GetRecipeCatalogueVersions(app.Type, 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.Fatalf("no published releases for %s in the recipe catalogue?", app.Recipe) logrus.Fatalf("no published releases for %s in the recipe catalogue?", app.Type)
} }
var availableDowngrades []string var availableDowngrades []string
@ -128,7 +119,7 @@ recipes.
var chosenDowngrade string var chosenDowngrade string
if !internal.Chaos { if !internal.Chaos {
if internal.Force || internal.NoInput { if internal.Force {
chosenDowngrade = availableDowngrades[0] chosenDowngrade = availableDowngrades[0]
logrus.Debugf("choosing %s as version to downgrade to (--force)", chosenDowngrade) logrus.Debugf("choosing %s as version to downgrade to (--force)", chosenDowngrade)
} else { } else {
@ -143,7 +134,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)
} }
} }
@ -151,13 +142,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.RECIPES_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)
@ -166,7 +157,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)
} }
@ -180,10 +171,6 @@ 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.SetUpdateLabel(compose, stackName, app.Env)
if !internal.Force { if !internal.Force {
if err := internal.NewVersionOverview(app, deployedVersion, chosenDowngrade, ""); err != nil { if err := internal.NewVersionOverview(app, deployedVersion, chosenDowngrade, ""); err != nil {

View File

@ -1,7 +1,6 @@
package app package app
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
@ -14,42 +13,41 @@ import (
"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, 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 +57,16 @@ 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) targetContainer, err := containerPkg.GetContainer(c.Context, cl, filters, true)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
cmd := c.Args()[2:] cmd := c.Args().Slice()[2:]
execCreateOpts := types.ExecConfig{ execCreateOpts := types.ExecConfig{
AttachStderr: true, AttachStderr: true,
AttachStdin: true, AttachStdin: true,

View File

@ -1,7 +1,6 @@
package app package app
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"os" "os"
@ -10,50 +9,34 @@ import (
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter" "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: "generate",
Name: "all, a", Aliases: []string{"g"},
Destination: &rmAllSecrets, Usage: "Generate secrets",
Usage: "Remove all secrets", ArgsUsage: "<secret> <version>",
} Flags: []cli.Flag{allSecretsFlag, internal.PassFlag},
var appSecretGenerateCommand = cli.Command{
Name: "generate",
Aliases: []string{"g"},
Usage: "Generate secrets",
ArgsUsage: "<domain> <secret> <version>",
Flags: []cli.Flag{
internal.DebugFlag,
allSecretsFlag,
internal.PassFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.AppNameComplete, 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) if c.Args().Len() == 1 && !allSecrets {
if err != nil {
logrus.Fatal(err)
}
if len(c.Args()) == 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)
} }
@ -75,23 +58,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)
} }
} }
secretVals, err := secret.GenerateSecrets(cl, secretsToCreate, app.StackName(), app.Server) secretVals, err := secret.GenerateSecrets(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)
} }
} }
@ -114,16 +95,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, BashComplete: autocomplete.AppNameComplete,
Description: ` Description: `
This command inserts a secret into an app environment. This command inserts a secret into an app environment.
@ -140,12 +117,7 @@ Example:
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) if c.Args().Len() != 4 {
if err != nil {
logrus.Fatal(err)
}
if len(c.Args()) != 4 {
internal.ShowSubcommandHelpAndError(c, errors.New("missing arguments?")) internal.ShowSubcommandHelpAndError(c, errors.New("missing arguments?"))
} }
@ -154,14 +126,12 @@ Example:
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 +140,29 @@ Example:
}, },
} }
// secretRm removes a secret. var appSecretRmCommand = &cli.Command{
func secretRm(cl *dockerClient.Client, app config.App, secretName, parsed string) error { Name: "remove",
if err := cl.SecretRemove(context.Background(), secretName); err != nil { Usage: "Remove a secret",
return err Aliases: []string{"rm"},
} Flags: []cli.Flag{allSecretsFlag, internal.PassFlag},
ArgsUsage: "<app> <secret-name>",
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",
Aliases: []string{"rm"},
Usage: "Remove a secret",
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
rmAllSecretsFlag,
internal.PassRemoveFlag,
},
Before: internal.SubCommandBefore,
ArgsUsage: "<domain> [<secret-name>]",
BashComplete: autocomplete.AppNameComplete, 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,71 +171,48 @@ 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)
}
return nil
}
} else {
match = true
if err := secretRm(cl, app, secretRemoteName, secretName); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} }
} else {
if parsed == secretToRm {
if err := cl.SecretRemove(c.Context, secretName); err != nil {
logrus.Fatal(err)
}
if internal.Pass {
if err := secret.PassRmSecret(parsed, app.StackName(), app.Server); err != nil {
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",
Usage: "List all secrets",
Aliases: []string{"ls"}, Aliases: []string{"ls"},
Flags: []cli.Flag{
internal.DebugFlag,
},
Before: internal.SubCommandBefore,
Usage: "List all secrets",
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)
@ -303,12 +225,9 @@ var appSecretLsCommand = cli.Command{
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)
} }
@ -344,12 +263,12 @@ var appSecretLsCommand = cli.Command{
BashComplete: autocomplete.AppNameComplete, BashComplete: autocomplete.AppNameComplete,
} }
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,26 +1,18 @@
package app package app
import ( import (
"context"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
stack "coopcloud.tech/abra/pkg/upstream/stack" stack "coopcloud.tech/abra/pkg/upstream/stack"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var appUndeployCommand = cli.Command{ var appUndeployCommand = &cli.Command{
Name: "undeploy", Name: "undeploy",
Aliases: []string{"un"}, Aliases: []string{"un"},
ArgsUsage: "<domain>", Usage: "Undeploy an app",
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
},
Before: internal.SubCommandBefore,
Usage: "Undeploy an app",
Description: ` Description: `
This does not destroy any of the application data. However, you should remain This does not destroy any of the application data. However, you should remain
vigilant, as your swarm installation will consider any previously attached vigilant, as your swarm installation will consider any previously attached
@ -37,7 +29,7 @@ volumes as eligiblef or pruning once undeployed.
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)
} }
@ -51,7 +43,7 @@ volumes as eligiblef or pruning once undeployed.
} }
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)
} }

View File

@ -1,7 +1,6 @@
package app package app
import ( import (
"context"
"fmt" "fmt"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
@ -10,41 +9,36 @@ import (
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/lint" "coopcloud.tech/abra/pkg/lint"
"coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/runtime"
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{"up"},
Usage: "Upgrade an app", Usage: "Upgrade an app",
ArgsUsage: "<domain>", ArgsUsage: "<app>",
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.ForceFlag, internal.ForceFlag,
internal.ChaosFlag, internal.ChaosFlag,
internal.NoDomainChecksFlag, internal.NoDomainChecksFlag,
internal.DontWaitConvergeFlag,
}, },
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 incrementing 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
@ -53,20 +47,12 @@ recipes.
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()
conf := runtime.New()
cl, err := client.New(app.Server) if err := recipe.EnsureUpToDate(app.Type); err != nil {
if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if !internal.Chaos { r, err := recipe.Get(app.Type)
if err := recipe.EnsureUpToDate(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
r, err := recipe.Get(app.Recipe, conf)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -75,9 +61,14 @@ recipes.
logrus.Fatal(err) logrus.Fatal(err)
} }
cl, err := client.New(app.Server)
if err != nil {
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)
} }
@ -91,17 +82,17 @@ recipes.
logrus.Fatal(err) logrus.Fatal(err)
} }
versions, err := recipe.GetRecipeCatalogueVersions(app.Recipe, catl) versions, err := recipe.GetRecipeCatalogueVersions(app.Type, 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.Fatalf("no published releases for %s in the recipe catalogue?", app.Recipe) logrus.Fatalf("no published releases for %s in the recipe catalogue?", app.Type)
} }
var availableUpgrades []string var availableUpgrades []string
if deployedVersion == "unknown" { if deployedVersion == "uknown" {
availableUpgrades = versions availableUpgrades = versions
logrus.Warnf("failed to determine version of deployed %s", app.Name) logrus.Warnf("failed to determine version of deployed %s", app.Name)
} }
@ -131,7 +122,7 @@ recipes.
var chosenUpgrade string var chosenUpgrade string
if len(availableUpgrades) > 0 && !internal.Chaos { if len(availableUpgrades) > 0 && !internal.Chaos {
if internal.Force || internal.NoInput { 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 {
@ -148,13 +139,13 @@ recipes.
// if release notes written after git tag published, read them before we // 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 // 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 // when we obviously will forget to write release notes before publishing
releaseNotes, err := internal.GetReleaseNotes(app.Recipe, chosenUpgrade) releaseNotes, err := internal.GetReleaseNotes(app.Type, chosenUpgrade)
if err != nil { if err != nil {
return err return err
} }
if !internal.Chaos { if !internal.Chaos {
if err := recipe.EnsureVersion(app.Recipe, chosenUpgrade); err != nil { if err := recipe.EnsureVersion(app.Type, chosenUpgrade); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} }
@ -162,13 +153,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 = recipe.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.RECIPES_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)
@ -177,7 +168,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)
} }
@ -191,10 +182,6 @@ 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.SetUpdateLabel(compose, stackName, app.Env)
if err := internal.NewVersionOverview(app, deployedVersion, chosenUpgrade, releaseNotes); err != nil { if err := internal.NewVersionOverview(app, deployedVersion, chosenUpgrade, releaseNotes); err != nil {
logrus.Fatal(err) logrus.Fatal(err)

View File

@ -1,18 +1,15 @@
package app package app
import ( import (
"context"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/runtime"
"coopcloud.tech/abra/pkg/upstream/stack" "coopcloud.tech/abra/pkg/upstream/stack"
"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"
) )
// getImagePath returns the image name // getImagePath returns the image name
@ -24,32 +21,25 @@ func getImagePath(image string) (string, error) {
path := reference.Path(img) path := reference.Path(img)
path = formatter.StripTagMeta(path) path = recipe.StripTagMeta(path)
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{
internal.DebugFlag,
internal.NoInputFlag,
},
Before: internal.SubCommandBefore,
Usage: "Show app versions",
Description: ` Description: `
Show all information about versioning related to a deployed app. This includes This command shows all information about versioning related to a deployed app.
the individual image names, tags and digests. But also the Co-op Cloud recipe This includes the individual image names, tags and digests. But also the Co-op
version. Cloud recipe version.
`, `,
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()
conf := runtime.New()
cl, err := client.New(app.Server) cl, err := client.New(app.Server)
if err != nil { if err != nil {
@ -58,7 +48,7 @@ version.
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)
} }
@ -71,7 +61,7 @@ version.
logrus.Fatalf("%s is not deployed?", app.Name) logrus.Fatalf("%s is not deployed?", app.Name)
} }
recipeMeta, err := recipe.GetRecipeMeta(app.Recipe, conf) recipeMeta, err := recipe.GetRecipeMeta(app.Type)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -87,12 +77,12 @@ version.
logrus.Fatalf("could not retrieve deployed version (%s) from recipe catalogue?", deployedVersion) logrus.Fatalf("could not retrieve deployed version (%s) from recipe catalogue?", deployedVersion)
} }
tableCol := []string{"version", "service", "image"} tableCol := []string{"version", "service", "image", "digest"}
table := formatter.CreateTable(tableCol) table := formatter.CreateTable(tableCol)
table.SetAutoMergeCellsByColumnIndex([]int{0}) table.SetAutoMergeCellsByColumnIndex([]int{0})
for serviceName, versionMeta := range versionsMeta { for serviceName, versionMeta := range versionsMeta {
table.Append([]string{deployedVersion, serviceName, versionMeta.Image}) table.Append([]string{deployedVersion, serviceName, versionMeta.Image, versionMeta.Digest})
} }
table.Render() table.Render()

View File

@ -1,50 +1,35 @@
package app package app
import ( import (
"context"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/formatter"
"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",
Aliases: []string{"ls"},
BashComplete: autocomplete.AppNameComplete, 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 { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
filters, err := app.Filters(false, true) table := formatter.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)
} }
@ -60,49 +45,36 @@ var appVolumeListCommand = cli.Command{
}, },
} }
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: ` Description: `
This command supports removing volumes associated with an app. The app in 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 question must be undeployed before you try to remove volumes. See "abra app
undeploy <domain>" for more. undeploy <app>" for more.
The command is interactive and will show a multiple select input which allows 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 you to make a seclection. Use the "?" key to see more help on navigating this
interface. interface.
Passing "--force/-f" will select all volumes for removal. Be careful. Passing "--force" will select all volumes for removal. Be careful.
`, `,
ArgsUsage: "<domain>", ArgsUsage: "<app>",
Aliases: []string{"rm"}, Aliases: []string{"rm"},
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.ForceFlag, internal.ForceFlag,
}, },
Before: internal.SubCommandBefore,
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)
}
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", Help: "'x' indicates selected, enter / return to confirm, ctrl-c to exit, vim mode is enabled",
@ -113,13 +85,11 @@ Passing "--force/-f" will select all volumes for removal. Be careful.
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
} }
err = client.RemoveVolumes(cl, context.Background(), app.Server, volumesToRemove, internal.Force) err = client.RemoveVolumes(c.Context, app.Server, volumesToRemove, internal.Force)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -131,12 +101,12 @@ Passing "--force/-f" will select all volumes for removal. Be careful.
BashComplete: autocomplete.AppNameComplete, BashComplete: autocomplete.AppNameComplete,
} }
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,
}, },

View File

@ -8,37 +8,72 @@ import (
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/formatter"
gitPkg "coopcloud.tech/abra/pkg/git" gitPkg "coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/limit"
"coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/recipe"
"github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var catalogueGenerateCommand = cli.Command{ // CatalogueSkipList is all the repos that are not recipes.
var CatalogueSkipList = map[string]bool{
"abra": true,
"abra-apps": true,
"abra-aur": true,
"abra-bash": 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,
"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,
"outline-with-patch": true,
"pyabra": true,
"radicle-seed-node": true,
"recipes": true,
"stack-ssh-deploy": true,
"swarm-cronjob": true,
"tagcmp": true,
"traefik-cert-dumper": true,
"tyop": true,
}
var catalogueGenerateCommand = &cli.Command{
Name: "generate", Name: "generate",
Aliases: []string{"g"}, Aliases: []string{"g"},
Usage: "Generate the recipe catalogue", Usage: "Generate the recipe catalogue",
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.PublishFlag, internal.PublishFlag,
internal.DryFlag, internal.DryFlag,
internal.SkipUpdatesFlag, internal.SkipUpdatesFlag,
internal.RegistryUsernameFlag,
internal.RegistryPasswordFlag,
}, },
Before: internal.SubCommandBefore,
Description: ` Description: `
Generate a new copy of the recipe catalogue which can be found on: This command generates a new copy of the recipe catalogue which can be found on:
https://recipes.coopcloud.tech (website that humans read) https://recipes.coopcloud.tech
https://recipes.coopcloud.tech/recipes.json (JSON that Abra reads)
It polls the entire git.coopcloud.tech/coop-cloud/... recipe repository It polls the entire git.coopcloud.tech/coop-cloud/... recipe repository
listing, parses README.md and git tags to produce recipe metadata which is listing, parses README.md and git tags of those repositories to produce recipe
loaded into the catalogue JSON file. metadata and produces a recipes JSON file.
It is possible to generate new metadata for a single recipe by passing It is possible to generate new metadata for a single recipe by passing
<recipe>. The existing local catalogue will be updated, not overwritten. <recipe>. The existing local catalogue will be updated, not overwritten.
@ -47,7 +82,7 @@ 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 If you have a Hub account you can have Abra log you in to avoid this. Pass
"--user" and "--pass". "--user" and "--pass".
Push your new release to git.coopcloud.tech with "-p/--publish". This requires Push your new release git.coopcloud.tech with "-p/--publish". This requires
that you have permission to git push to these repositories and have your SSH that you have permission to git push to these repositories and have your SSH
keys configured on your account. keys configured on your account.
`, `,
@ -58,8 +93,10 @@ keys configured on your account.
internal.ValidateRecipe(c) internal.ValidateRecipe(c)
} }
if err := catalogue.EnsureUpToDate(); err != nil { catalogueDir := path.Join(config.ABRA_DIR, "catalogue")
logrus.Fatal(err) url := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, "recipes")
if err := gitPkg.Clone(catalogueDir, url); err != nil {
return err
} }
repos, err := recipe.ReadReposMetadata() repos, err := recipe.ReadReposMetadata()
@ -79,7 +116,7 @@ keys configured on your account.
if !internal.SkipUpdates { if !internal.SkipUpdates {
logrus.Warn(logMsg) logrus.Warn(logMsg)
if err := recipe.UpdateRepositories(repos, recipeName); err != nil { if err := updateRepositories(repos, recipeName); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} }
@ -92,14 +129,18 @@ keys configured on your account.
continue continue
} }
if _, exists := catalogue.CatalogueSkipList[recipeMeta.Name]; exists { if _, exists := CatalogueSkipList[recipeMeta.Name]; exists {
catlBar.Add(1) catlBar.Add(1)
continue continue
} }
versions, err := recipe.GetRecipeVersions(recipeMeta.Name) versions, err := recipe.GetRecipeVersions(
recipeMeta.Name,
internal.RegistryUsername,
internal.RegistryPassword,
)
if err != nil { if err != nil {
logrus.Warn(err) logrus.Fatal(err)
} }
features, category, err := recipe.GetRecipeFeaturesAndCategory(recipeMeta.Name) features, category, err := recipe.GetRecipeFeaturesAndCategory(recipeMeta.Name)
@ -176,7 +217,7 @@ keys configured on your account.
logrus.Fatal(err) logrus.Fatal(err)
} }
sshURL := fmt.Sprintf(config.SSH_URL_TEMPLATE, config.CATALOGUE_JSON_REPO_NAME) sshURL := fmt.Sprintf(config.SSH_URL_TEMPLATE, "recipes")
if err := gitPkg.CreateRemote(repo, "origin-ssh", sshURL, internal.Dry); err != nil { if err := gitPkg.CreateRemote(repo, "origin-ssh", sshURL, internal.Dry); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -197,7 +238,7 @@ keys configured on your account.
} }
if !internal.Dry && internal.Publish { if !internal.Dry && internal.Publish {
url := fmt.Sprintf("%s/%s/commit/%s", config.REPOS_BASE_URL, config.CATALOGUE_JSON_REPO_NAME, head.Hash()) url := fmt.Sprintf("%s/recipes/commit/%s", config.REPOS_BASE_URL, head.Hash())
logrus.Infof("new changes published: %s", url) logrus.Infof("new changes published: %s", url)
} }
@ -211,13 +252,72 @@ keys configured on your account.
} }
// 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",
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,
}, },
} }
func updateRepositories(repos recipe.RepoCatalogue, recipeName string) error {
var barLength int
if recipeName != "" {
barLength = 1
} else {
barLength = len(repos)
}
cloneLimiter := limit.New(10)
retrieveBar := formatter.CreateProgressbar(barLength, "ensuring recipes are cloned & up-to-date...")
ch := make(chan string, barLength)
for _, repoMeta := range repos {
go func(rm recipe.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.RECIPES_DIR, rm.Name)
if err := gitPkg.Clone(recipeDir, rm.CloneURL); err != nil {
logrus.Fatal(err)
}
isClean, err := gitPkg.IsClean(recipeDir)
if err != nil {
logrus.Fatal(err)
}
if !isClean {
logrus.Fatalf("%s has locally unstaged changes", rm.Name)
}
if err := recipe.EnsureUpToDate(rm.Name); err != nil {
logrus.Fatal(err)
}
ch <- rm.Name
retrieveBar.Add(1)
}(repoMeta)
}
for range repos {
<-ch // wait for everything
}
return nil
}

View File

@ -14,31 +14,35 @@ import (
"coopcloud.tech/abra/cli/recipe" "coopcloud.tech/abra/cli/recipe"
"coopcloud.tech/abra/cli/record" "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/web" "coopcloud.tech/abra/pkg/web"
logrusStack "github.com/Gurpartap/logrus-stack"
"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 // AutoCompleteCommand helps people set up auto-complete in their shells
var AutoCompleteCommand = cli.Command{ var AutoCompleteCommand = &cli.Command{
Name: "autocomplete", Name: "autocomplete",
Aliases: []string{"ac"},
Usage: "Configure shell autocompletion (recommended)", Usage: "Configure shell autocompletion (recommended)",
Aliases: []string{"ac"},
Description: ` Description: `
Set up auto-completion in your shell by downloading the relevant files and This command helps set up autocompletion in your shell by downloading the
laying out what additional information must be loaded. Supported shells are as relevant autocompletion files and laying out what additional information must
follows: bash, fish, fizsh & zsh. be loaded.
Example: Example:
abra autocomplete bash abra autocomplete bash
Supported shells are as follows:
fizsh
zsh
bash
`, `,
ArgsUsage: "<shell>", ArgsUsage: "<shell>",
Flags: []cli.Flag{
internal.DebugFlag,
},
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
shellType := c.Args().First() shellType := c.Args().First()
@ -50,7 +54,6 @@ Example:
"bash": true, "bash": true,
"zsh": true, "zsh": true,
"fizsh": true, "fizsh": true,
"fish": true,
} }
if _, ok := supportedShells[shellType]; !ok { if _, ok := supportedShells[shellType]; !ok {
@ -81,27 +84,19 @@ Example:
switch shellType { switch shellType {
case "bash": case "bash":
fmt.Println(fmt.Sprintf(` fmt.Println(fmt.Sprintf(`
# Run the following commands to install auto-completion # Run the following commands to install autocompletion
sudo mkdir /etc/bash_completion.d/ sudo mkdir /etc/bash_completion.d/
sudo cp %s /etc/bash_completion.d/abra sudo cp %s /etc/bash_completion.d/abra
echo "source /etc/bash_completion.d/abra" >> ~/.bashrc echo "source /etc/bash_completion.d/abra" >> ~/.bashrc
# To test, run the following: "abra app <hit tab key>" - you should see command completion! # And finally run "abra app ps <hit tab key>" to test things are working, you should see app names listed!
`, autocompletionFile)) `, autocompletionFile))
case "zsh": case "zsh":
fmt.Println(fmt.Sprintf(` fmt.Println(fmt.Sprintf(`
# Run the following commands to install auto-completion # Run the following commands to install autocompletion
sudo mkdir /etc/zsh/completion.d/ sudo mkdir /etc/zsh/completion.d/
sudo cp %s /etc/zsh/completion.d/abra sudo cp %s /etc/zsh/completion.d/abra
echo "PROG=abra\n_CLI_ZSH_AUTOCOMPLETE_HACK=1\nsource /etc/zsh/completion.d/abra" >> ~/.zshrc 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! # And finally run "abra app ps <hit tab key>" to test things are working, you should see app names listed!
`, 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)) `, autocompletionFile))
} }
@ -110,16 +105,18 @@ echo "source /etc/fish/completions/abra" >> ~/.config/fish/config.fish
} }
// UpgradeCommand upgrades abra in-place. // UpgradeCommand upgrades abra in-place.
var UpgradeCommand = cli.Command{ var UpgradeCommand = &cli.Command{
Name: "upgrade", Name: "upgrade",
Aliases: []string{"u"},
Usage: "Upgrade Abra itself", Usage: "Upgrade Abra itself",
Aliases: []string{"u"},
Description: ` Description: `
Upgrade Abra in-place with the latest stable or release candidate. This command allows you to upgrade Abra in-place with the latest stable or
release candidate.
Pass "-r/--rc" to install the latest release candidate. Please bear in mind If you would like to install the latest release candidate, please pass the
that it may contain catastrophic bugs. Thank you very much for the testing "--rc" option. Please bear in mind that the latest release candidate may have
efforts! some catastrophic bugs contained in it. In any case, thank you very much for
the testing efforts!
`, `,
Flags: []cli.Flag{internal.RCFlag}, Flags: []cli.Flag{internal.RCFlag},
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
@ -153,7 +150,7 @@ 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,
@ -162,18 +159,37 @@ func newAbraApp(version, commit string) *cli.App {
UpgradeCommand, UpgradeCommand,
AutoCompleteCommand, AutoCompleteCommand,
}, },
BashComplete: autocomplete.SubcommandComplete, Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
},
Authors: []*cli.Author{
// 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 love
{Name: "3wordchant"},
{Name: "decentral1se"},
{Name: "kawaiipunk"},
{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 internal.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,
path.Join(config.SERVERS_DIR), path.Join(config.SERVERS_DIR),
path.Join(config.RECIPES_DIR), path.Join(config.RECIPES_DIR),
path.Join(config.VENDOR_DIR), path.Join(config.VENDOR_DIR),
path.Join(config.BACKUP_DIR),
} }
for _, path := range paths { for _, path := range paths {
@ -189,7 +205,6 @@ func newAbraApp(version, commit string) *cli.App {
return nil return nil
} }
return app return app
} }

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,7 +1,6 @@
package internal package internal
import ( import (
"context"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"os" "os"
@ -15,30 +14,23 @@ import (
"coopcloud.tech/abra/pkg/git" "coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/lint" "coopcloud.tech/abra/pkg/lint"
"coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/runtime"
"coopcloud.tech/abra/pkg/upstream/stack" "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"
) )
// DeployAction is the main command-line action for this package // DeployAction is the main command-line action for this package
func DeployAction(c *cli.Context) error { func DeployAction(c *cli.Context) error {
app := ValidateApp(c) app := ValidateApp(c)
conf := runtime.New()
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
}
if !Chaos { if !Chaos {
if err := recipe.EnsureUpToDate(app.Recipe); err != nil { if err := recipe.EnsureUpToDate(app.Type); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} }
r, err := recipe.Get(app.Recipe, conf) r, err := recipe.Get(app.Type)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -47,9 +39,14 @@ func DeployAction(c *cli.Context) error {
logrus.Fatal(err) logrus.Fatal(err)
} }
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
}
logrus.Debugf("checking whether %s is already deployed", app.StackName()) logrus.Debugf("checking whether %s is already deployed", app.StackName())
isDeployed, deployedVersion, err := stack.IsDeployed(context.Background(), cl, app.StackName()) isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, app.StackName())
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -68,24 +65,24 @@ func DeployAction(c *cli.Context) error {
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
versions, err := recipe.GetRecipeCatalogueVersions(app.Recipe, catl) versions, err := recipe.GetRecipeCatalogueVersions(app.Type, catl)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if len(versions) > 0 { if len(versions) > 0 {
version = versions[len(versions)-1] version = versions[len(versions)-1]
logrus.Debugf("choosing %s as version to deploy", version) logrus.Debugf("choosing %s as version to deploy", version)
if err := recipe.EnsureVersion(app.Recipe, version); err != nil { if err := recipe.EnsureVersion(app.Type, version); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} else { } else {
head, err := git.GetRecipeHead(app.Recipe) head, err := git.GetRecipeHead(app.Type)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
version = formatter.SmallSHA(head.String()) version = formatter.SmallSHA(head.String())
logrus.Warn("no versions detected, using latest commit") logrus.Warn("no versions detected, using latest commit")
if err := recipe.EnsureLatest(app.Recipe, conf); err != nil { if err := recipe.EnsureLatest(app.Type); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} }
@ -93,13 +90,13 @@ func DeployAction(c *cli.Context) error {
if version == "unknown" && !Chaos { if version == "unknown" && !Chaos {
logrus.Debugf("choosing %s as version to deploy", version) logrus.Debugf("choosing %s as version to deploy", version)
if err := recipe.EnsureVersion(app.Recipe, version); err != nil { if err := recipe.EnsureVersion(app.Type, version); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} }
if version != "unknown" && !Chaos { if version != "unknown" && !Chaos {
if err := recipe.EnsureVersion(app.Recipe, version); err != nil { if err := recipe.EnsureVersion(app.Type, version); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} }
@ -107,13 +104,13 @@ func DeployAction(c *cli.Context) error {
if Chaos { if Chaos {
logrus.Warnf("chaos mode engaged") logrus.Warnf("chaos mode engaged")
var err error var err error
version, err = recipe.ChaosVersion(app.Recipe) version, 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.RECIPES_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)
@ -122,7 +119,7 @@ func DeployAction(c *cli.Context) error {
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)
} }
@ -136,23 +133,20 @@ func DeployAction(c *cli.Context) error {
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
config.ExposeAllEnv(app.StackName(), compose, app.Env)
config.SetRecipeLabel(compose, app.StackName(), app.Recipe)
config.SetChaosLabel(compose, app.StackName(), Chaos)
config.SetUpdateLabel(compose, app.StackName(), app.Env)
if err := DeployOverview(app, version, "continue with deployment?"); err != nil { if err := DeployOverview(app, version, "continue with deployment?"); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if !NoDomainChecks { if !NoDomainChecks {
domainName, ok := app.Env["DOMAIN"] domainName := app.Env["DOMAIN"]
if ok { ipv4, err := dns.EnsureIPv4(domainName)
if _, err = dns.EnsureDomainsResolveSameIPv4(domainName, app.Server); err != nil { if err != nil || ipv4 == "" {
logrus.Fatal(err) logrus.Fatalf("could not find an IP address assigned to %s?", domainName)
} }
} else {
logrus.Warn("skipping domain checks as no DOMAIN=... configured for app") if _, err = dns.EnsureDomainsResolveSameIPv4(domainName, app.Server); err != nil {
logrus.Fatal(err)
} }
} else { } else {
logrus.Warn("skipping domain checks as requested") logrus.Warn("skipping domain checks as requested")
@ -167,7 +161,7 @@ func DeployAction(c *cli.Context) error {
// 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", "app name", "version"}
table := formatter.CreateTable(tableCol) table := formatter.CreateTable(tableCol)
deployConfig := "compose.yml" deployConfig := "compose.yml"
@ -180,7 +174,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.Name, version})
table.Render() table.Render()
if NoInput { if NoInput {
@ -205,7 +199,7 @@ func DeployOverview(app config.App, version, message string) error {
// NewVersionOverview shows an upgrade or downgrade overview // NewVersionOverview shows an upgrade or downgrade overview
func NewVersionOverview(app config.App, currentVersion, newVersion, releaseNotes string) error { func NewVersionOverview(app config.App, currentVersion, newVersion, releaseNotes string) error {
tableCol := []string{"server", "recipe", "config", "domain", "current version", "to be deployed"} tableCol := []string{"server", "compose", "domain", "app name", "current version", "to be deployed"}
table := formatter.CreateTable(tableCol) table := formatter.CreateTable(tableCol)
deployConfig := "compose.yml" deployConfig := "compose.yml"
@ -218,12 +212,12 @@ func NewVersionOverview(app config.App, currentVersion, newVersion, releaseNotes
server = "local" server = "local"
} }
table.Append([]string{server, app.Recipe, deployConfig, app.Domain, currentVersion, newVersion}) table.Append([]string{server, deployConfig, app.Domain, app.Name, currentVersion, newVersion})
table.Render() table.Render()
if releaseNotes == "" { if releaseNotes == "" {
var err error var err error
releaseNotes, err = GetReleaseNotes(app.Recipe, newVersion) releaseNotes, err = GetReleaseNotes(app.Type, newVersion)
if err != nil { if err != nil {
return err return err
} }

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

@ -1,11 +1,7 @@
package internal package internal
import ( import (
"os" "github.com/urfave/cli/v2"
logrusStack "github.com/Gurpartap/logrus-stack"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
) )
// Secrets stores the variable from SecretsFlag // Secrets stores the variable from SecretsFlag
@ -13,7 +9,9 @@ var Secrets bool
// SecretsFlag turns on/off automatically generating secrets // SecretsFlag turns on/off automatically generating secrets
var SecretsFlag = &cli.BoolFlag{ var SecretsFlag = &cli.BoolFlag{
Name: "secrets, S", Name: "secrets",
Aliases: []string{"ss"},
Value: false,
Usage: "Automatically generate secrets", Usage: "Automatically generate secrets",
Destination: &Secrets, Destination: &Secrets,
} }
@ -23,19 +21,22 @@ var Pass bool
// PassFlag turns on/off storing generated secrets in pass // PassFlag turns on/off storing generated secrets in pass
var PassFlag = &cli.BoolFlag{ var PassFlag = &cli.BoolFlag{
Name: "pass, p", Name: "pass",
Aliases: []string{"p"},
Value: false,
Usage: "Store the generated secrets in a local pass store", Usage: "Store the generated secrets in a local pass store",
Destination: &Pass, Destination: &Pass,
} }
// PassRemove stores the variable for PassRemoveFlag // Context is temp
var PassRemove bool var Context string
// PassRemoveFlag turns on/off removing generated secrets from pass // ContextFlag is temp
var PassRemoveFlag = &cli.BoolFlag{ var ContextFlag = &cli.StringFlag{
Name: "pass, p", Name: "context",
Usage: "Remove generated secrets from a local pass store", Value: "",
Destination: &PassRemove, Aliases: []string{"c"},
Destination: &Context,
} }
// Force force functionality without asking. // Force force functionality without asking.
@ -43,7 +44,9 @@ var Force bool
// ForceFlag turns on/off force functionality. // ForceFlag turns on/off force functionality.
var ForceFlag = &cli.BoolFlag{ var ForceFlag = &cli.BoolFlag{
Name: "force, f", Name: "force",
Value: false,
Aliases: []string{"f"},
Usage: "Perform action without further prompt. Use with care!", Usage: "Perform action without further prompt. Use with care!",
Destination: &Force, Destination: &Force,
} }
@ -53,7 +56,9 @@ var Chaos bool
// ChaosFlag turns on/off chaos functionality. // ChaosFlag turns on/off chaos functionality.
var ChaosFlag = &cli.BoolFlag{ var ChaosFlag = &cli.BoolFlag{
Name: "chaos, C", Name: "chaos",
Value: false,
Aliases: []string{"ch"},
Usage: "Deploy uncommitted recipes changes. Use with care!", Usage: "Deploy uncommitted recipes changes. Use with care!",
Destination: &Chaos, Destination: &Chaos,
} }
@ -63,15 +68,18 @@ var DNSProvider string
// DNSProviderFlag selects a DNS provider. // DNSProviderFlag selects a DNS provider.
var DNSProviderFlag = &cli.StringFlag{ var DNSProviderFlag = &cli.StringFlag{
Name: "provider, p", Name: "provider",
Value: "", Value: "",
Aliases: []string{"p"},
Usage: "DNS provider", Usage: "DNS provider",
Destination: &DNSProvider, Destination: &DNSProvider,
} }
var NoInput bool var NoInput bool
var NoInputFlag = &cli.BoolFlag{ var NoInputFlag = &cli.BoolFlag{
Name: "no-input, n", Name: "no-input",
Value: false,
Aliases: []string{"n"},
Usage: "Toggle non-interactive mode", Usage: "Toggle non-interactive mode",
Destination: &NoInput, Destination: &NoInput,
} }
@ -79,8 +87,9 @@ var NoInputFlag = &cli.BoolFlag{
var DNSType string var DNSType string
var DNSTypeFlag = &cli.StringFlag{ var DNSTypeFlag = &cli.StringFlag{
Name: "record-type, rt", Name: "type",
Value: "", Value: "",
Aliases: []string{"t"},
Usage: "Domain name record type (e.g. A)", Usage: "Domain name record type (e.g. A)",
Destination: &DNSType, Destination: &DNSType,
} }
@ -88,8 +97,9 @@ var DNSTypeFlag = &cli.StringFlag{
var DNSName string var DNSName string
var DNSNameFlag = &cli.StringFlag{ var DNSNameFlag = &cli.StringFlag{
Name: "record-name, rn", Name: "name",
Value: "", Value: "",
Aliases: []string{"n"},
Usage: "Domain name record name (e.g. mysubdomain)", Usage: "Domain name record name (e.g. mysubdomain)",
Destination: &DNSName, Destination: &DNSName,
} }
@ -97,16 +107,18 @@ var DNSNameFlag = &cli.StringFlag{
var DNSValue string var DNSValue string
var DNSValueFlag = &cli.StringFlag{ var DNSValueFlag = &cli.StringFlag{
Name: "record-value, rv", Name: "value",
Value: "", Value: "",
Aliases: []string{"v"},
Usage: "Domain name record value (e.g. 192.168.1.1)", Usage: "Domain name record value (e.g. 192.168.1.1)",
Destination: &DNSValue, Destination: &DNSValue,
} }
var DNSTTL string var DNSTTL string
var DNSTTLFlag = &cli.StringFlag{ var DNSTTLFlag = &cli.StringFlag{
Name: "record-ttl, rl", Name: "ttl",
Value: "600s", Value: "600s",
Aliases: []string{"T"},
Usage: "Domain name TTL value (seconds)", Usage: "Domain name TTL value (seconds)",
Destination: &DNSTTL, Destination: &DNSTTL,
} }
@ -114,8 +126,9 @@ var DNSTTLFlag = &cli.StringFlag{
var DNSPriority int var DNSPriority int
var DNSPriorityFlag = &cli.IntFlag{ var DNSPriorityFlag = &cli.IntFlag{
Name: "record-priority, rp", Name: "priority",
Value: 10, Value: 10,
Aliases: []string{"P"},
Usage: "Domain name priority value", Usage: "Domain name priority value",
Destination: &DNSPriority, Destination: &DNSPriority,
} }
@ -123,7 +136,8 @@ var DNSPriorityFlag = &cli.IntFlag{
var ServerProvider string var ServerProvider string
var ServerProviderFlag = &cli.StringFlag{ var ServerProviderFlag = &cli.StringFlag{
Name: "provider, p", Name: "provider",
Aliases: []string{"p"},
Usage: "3rd party server provider", Usage: "3rd party server provider",
Destination: &ServerProvider, Destination: &ServerProvider,
} }
@ -131,8 +145,9 @@ var ServerProviderFlag = &cli.StringFlag{
var CapsulInstanceURL string var CapsulInstanceURL string
var CapsulInstanceURLFlag = &cli.StringFlag{ var CapsulInstanceURLFlag = &cli.StringFlag{
Name: "capsul-url, cu", Name: "capsul-url",
Value: "yolo.servers.coop", Value: "yolo.servers.coop",
Aliases: []string{"cu"},
Usage: "capsul instance URL", Usage: "capsul instance URL",
Destination: &CapsulInstanceURL, Destination: &CapsulInstanceURL,
} }
@ -140,8 +155,9 @@ var CapsulInstanceURLFlag = &cli.StringFlag{
var CapsulName string var CapsulName string
var CapsulNameFlag = &cli.StringFlag{ var CapsulNameFlag = &cli.StringFlag{
Name: "capsul-name, cn", Name: "capsul-name",
Value: "", Value: "",
Aliases: []string{"cn"},
Usage: "capsul name", Usage: "capsul name",
Destination: &CapsulName, Destination: &CapsulName,
} }
@ -149,8 +165,9 @@ var CapsulNameFlag = &cli.StringFlag{
var CapsulType string var CapsulType string
var CapsulTypeFlag = &cli.StringFlag{ var CapsulTypeFlag = &cli.StringFlag{
Name: "capsul-type, ct", Name: "capsul-type",
Value: "f1-xs", Value: "f1-xs",
Aliases: []string{"ct"},
Usage: "capsul type", Usage: "capsul type",
Destination: &CapsulType, Destination: &CapsulType,
} }
@ -158,33 +175,38 @@ var CapsulTypeFlag = &cli.StringFlag{
var CapsulImage string var CapsulImage string
var CapsulImageFlag = &cli.StringFlag{ var CapsulImageFlag = &cli.StringFlag{
Name: "capsul-image, ci", Name: "capsul-image",
Value: "debian10", Value: "debian10",
Aliases: []string{"ci"},
Usage: "capsul image", Usage: "capsul image",
Destination: &CapsulImage, Destination: &CapsulImage,
} }
var CapsulSSHKeys cli.StringSlice var CapsulSSHKeys cli.StringSlice
var CapsulSSHKeysFlag = &cli.StringSliceFlag{ var CapsulSSHKeysFlag = &cli.StringSliceFlag{
Name: "capsul-ssh-keys, cs", Name: "capsul-ssh-keys",
Usage: "capsul SSH key", Aliases: []string{"cs"},
Value: &CapsulSSHKeys, Usage: "capsul SSH key",
Destination: &CapsulSSHKeys,
} }
var CapsulAPIToken string var CapsulAPIToken string
var CapsulAPITokenFlag = &cli.StringFlag{ var CapsulAPITokenFlag = &cli.StringFlag{
Name: "capsul-token, ca", Name: "capsul-token",
Aliases: []string{"ca"},
Usage: "capsul API token", Usage: "capsul API token",
EnvVar: "CAPSUL_TOKEN", EnvVars: []string{"CAPSUL_TOKEN"},
Destination: &CapsulAPIToken, Destination: &CapsulAPIToken,
} }
var HetznerCloudName string var HetznerCloudName string
var HetznerCloudNameFlag = &cli.StringFlag{ var HetznerCloudNameFlag = &cli.StringFlag{
Name: "hetzner-name, hn", Name: "hetzner-name",
Value: "", Value: "",
Aliases: []string{"hn"},
Usage: "hetzner cloud name", Usage: "hetzner cloud name",
Destination: &HetznerCloudName, Destination: &HetznerCloudName,
} }
@ -192,7 +214,8 @@ var HetznerCloudNameFlag = &cli.StringFlag{
var HetznerCloudType string var HetznerCloudType string
var HetznerCloudTypeFlag = &cli.StringFlag{ var HetznerCloudTypeFlag = &cli.StringFlag{
Name: "hetzner-type, ht", Name: "hetzner-type",
Aliases: []string{"ht"},
Usage: "hetzner cloud type", Usage: "hetzner cloud type",
Destination: &HetznerCloudType, Destination: &HetznerCloudType,
Value: "cx11", Value: "cx11",
@ -201,7 +224,8 @@ var HetznerCloudTypeFlag = &cli.StringFlag{
var HetznerCloudImage string var HetznerCloudImage string
var HetznerCloudImageFlag = &cli.StringFlag{ var HetznerCloudImageFlag = &cli.StringFlag{
Name: "hetzner-image, hi", Name: "hetzner-image",
Aliases: []string{"hi"},
Usage: "hetzner cloud image", Usage: "hetzner cloud image",
Value: "debian-10", Value: "debian-10",
Destination: &HetznerCloudImage, Destination: &HetznerCloudImage,
@ -210,15 +234,17 @@ var HetznerCloudImageFlag = &cli.StringFlag{
var HetznerCloudSSHKeys cli.StringSlice var HetznerCloudSSHKeys cli.StringSlice
var HetznerCloudSSHKeysFlag = &cli.StringSliceFlag{ var HetznerCloudSSHKeysFlag = &cli.StringSliceFlag{
Name: "hetzner-ssh-keys, hs", Name: "hetzner-ssh-keys",
Usage: "hetzner cloud SSH keys (e.g. me@foo.com)", Aliases: []string{"hs"},
Value: &HetznerCloudSSHKeys, Usage: "hetzner cloud SSH keys (e.g. me@foo.com)",
Destination: &HetznerCloudSSHKeys,
} }
var HetznerCloudLocation string var HetznerCloudLocation string
var HetznerCloudLocationFlag = &cli.StringFlag{ var HetznerCloudLocationFlag = &cli.StringFlag{
Name: "hetzner-location, hl", Name: "hetzner-location",
Aliases: []string{"hl"},
Usage: "hetzner cloud server location", Usage: "hetzner cloud server location",
Value: "hel1", Value: "hel1",
Destination: &HetznerCloudLocation, Destination: &HetznerCloudLocation,
@ -227,9 +253,10 @@ var HetznerCloudLocationFlag = &cli.StringFlag{
var HetznerCloudAPIToken string var HetznerCloudAPIToken string
var HetznerCloudAPITokenFlag = &cli.StringFlag{ var HetznerCloudAPITokenFlag = &cli.StringFlag{
Name: "hetzner-token, ha", Name: "hetzner-token",
Aliases: []string{"ha"},
Usage: "hetzner cloud API token", Usage: "hetzner cloud API token",
EnvVar: "HCLOUD_TOKEN", EnvVars: []string{"HCLOUD_TOKEN"},
Destination: &HetznerCloudAPIToken, Destination: &HetznerCloudAPIToken,
} }
@ -238,69 +265,73 @@ var Debug bool
// DebugFlag turns on/off verbose logging down to the DEBUG level. // DebugFlag turns on/off verbose logging down to the DEBUG level.
var DebugFlag = &cli.BoolFlag{ var DebugFlag = &cli.BoolFlag{
Name: "debug, d", Name: "debug",
Aliases: []string{"d"},
Value: false,
Destination: &Debug, Destination: &Debug,
Usage: "Show DEBUG messages", Usage: "Show DEBUG messages",
} }
// 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 // RC signifies the latest release candidate
var RC bool var RC bool
// RCFlag chooses the latest release candidate for install // RCFlag chooses the latest release candidate for install
var RCFlag = &cli.BoolFlag{ var RCFlag = &cli.BoolFlag{
Name: "rc, r", Name: "rc",
Value: false,
Destination: &RC, Destination: &RC,
Usage: "Insatll the latest release candidate", Usage: "Insatll the latest release candidate",
} }
var Major bool var Major bool
var MajorFlag = &cli.BoolFlag{ var MajorFlag = &cli.BoolFlag{
Name: "major, x", Name: "major",
Usage: "Increase the major part of the version", Usage: "Increase the major part of the version",
Value: false,
Aliases: []string{"ma", "x"},
Destination: &Major, Destination: &Major,
} }
var Minor bool var Minor bool
var MinorFlag = &cli.BoolFlag{ var MinorFlag = &cli.BoolFlag{
Name: "minor, y", Name: "minor",
Usage: "Increase the minor part of the version", Usage: "Increase the minor part of the version",
Value: false,
Aliases: []string{"mi", "y"},
Destination: &Minor, Destination: &Minor,
} }
var Patch bool var Patch bool
var PatchFlag = &cli.BoolFlag{ var PatchFlag = &cli.BoolFlag{
Name: "patch, z", Name: "patch",
Usage: "Increase the patch part of the version", Usage: "Increase the patch part of the version",
Value: false,
Aliases: []string{"pa", "z"},
Destination: &Patch, Destination: &Patch,
} }
var Dry bool var Dry bool
var DryFlag = &cli.BoolFlag{ var DryFlag = &cli.BoolFlag{
Name: "dry-run, r", Name: "dry-run",
Usage: "Only reports changes that would be made", Usage: "Only reports changes that would be made",
Value: false,
Aliases: []string{"d"},
Destination: &Dry, Destination: &Dry,
} }
var Publish bool var Publish bool
var PublishFlag = &cli.BoolFlag{ var PublishFlag = &cli.BoolFlag{
Name: "publish, p", Name: "publish",
Usage: "Publish changes to git.coopcloud.tech", Usage: "Publish changes to git.coopcloud.tech",
Value: false,
Aliases: []string{"p"},
Destination: &Publish, Destination: &Publish,
} }
var Domain string var Domain string
var DomainFlag = &cli.StringFlag{ var DomainFlag = &cli.StringFlag{
Name: "domain, D", Name: "domain",
Aliases: []string{"d"},
Value: "", Value: "",
Usage: "Choose a domain name", Usage: "Choose a domain name",
Destination: &Domain, Destination: &Domain,
@ -308,92 +339,150 @@ var DomainFlag = &cli.StringFlag{
var NewAppServer string var NewAppServer string
var NewAppServerFlag = &cli.StringFlag{ var NewAppServerFlag = &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: &NewAppServer, Destination: &NewAppServer,
} }
var NewAppName string
var NewAppNameFlag = &cli.StringFlag{
Name: "app-name",
Aliases: []string{"a"},
Value: "",
Usage: "Choose an app name",
Destination: &NewAppName,
}
var NoDomainChecks bool var NoDomainChecks bool
var NoDomainChecksFlag = &cli.BoolFlag{ var NoDomainChecksFlag = &cli.BoolFlag{
Name: "no-domain-checks, D", Name: "no-domain-checks",
Aliases: []string{"nd"},
Value: false,
Usage: "Disable app domain sanity checks", Usage: "Disable app domain sanity checks",
Destination: &NoDomainChecks, Destination: &NoDomainChecks,
} }
var StdErrOnly bool var StdErrOnly bool
var StdErrOnlyFlag = &cli.BoolFlag{ var StdErrOnlyFlag = &cli.BoolFlag{
Name: "stderr, s", Name: "stderr",
Aliases: []string{"s"},
Value: false,
Usage: "Only tail stderr", Usage: "Only tail stderr",
Destination: &StdErrOnly, Destination: &StdErrOnly,
} }
var SinceLogs string var AutoDNSRecord bool
var SinceLogsFlag = &cli.StringFlag{ var AutoDNSRecordFlag = &cli.BoolFlag{
Name: "since, S", Name: "auto",
Value: "", Aliases: []string{"a"},
Usage: "tail logs since YYYY-MM-DDTHH:MM:SSZ", Value: false,
Destination: &SinceLogs, Usage: "Automatically configure DNS records",
Destination: &AutoDNSRecord,
} }
var DontWaitConverge bool var DontWaitConverge bool
var DontWaitConvergeFlag = &cli.BoolFlag{ var DontWaitConvergeFlag = &cli.BoolFlag{
Name: "no-converge-checks, c", Name: "no-converge-checks",
Aliases: []string{"nc"},
Value: false,
Usage: "Don't wait for converge logic checks", Usage: "Don't wait for converge logic checks",
Destination: &DontWaitConverge, Destination: &DontWaitConverge,
} }
var Watch bool var Watch bool
var WatchFlag = &cli.BoolFlag{ var WatchFlag = &cli.BoolFlag{
Name: "watch, w", Name: "watch",
Aliases: []string{"w"},
Value: false,
Usage: "Watch status by polling repeatedly", Usage: "Watch status by polling repeatedly",
Destination: &Watch, Destination: &Watch,
} }
var OnlyErrors bool var OnlyErrors bool
var OnlyErrorFlag = &cli.BoolFlag{ var OnlyErrorFlag = &cli.BoolFlag{
Name: "errors, e", Name: "errors",
Aliases: []string{"e"},
Value: false,
Usage: "Only show errors", Usage: "Only show errors",
Destination: &OnlyErrors, Destination: &OnlyErrors,
} }
var SkipUpdates bool var SkipUpdates bool
var SkipUpdatesFlag = &cli.BoolFlag{ var SkipUpdatesFlag = &cli.BoolFlag{
Name: "skip-updates, s", Name: "skip-updates",
Aliases: []string{"s"},
Value: false,
Usage: "Skip updating recipe repositories", Usage: "Skip updating recipe repositories",
Destination: &SkipUpdates, Destination: &SkipUpdates,
} }
var AllTags bool var RegistryUsername string
var AllTagsFlag = &cli.BoolFlag{ var RegistryUsernameFlag = &cli.StringFlag{
Name: "all-tags, a", Name: "username",
Usage: "List all tags, not just upgrades", Aliases: []string{"user"},
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: "", Value: "",
Usage: "User to run command within a service context", Usage: "Registry username",
Destination: &RemoteUser, EnvVars: []string{"REGISTRY_USERNAME"},
Destination: &RegistryUsername,
} }
// SubCommandBefore wires up pre-action machinery (e.g. --debug handling). var RegistryPassword string
func SubCommandBefore(c *cli.Context) error { var RegistryPasswordFlag = &cli.StringFlag{
if Debug { Name: "password",
logrus.SetLevel(logrus.DebugLevel) Aliases: []string{"pass"},
logrus.SetFormatter(&logrus.TextFormatter{}) Value: "",
logrus.SetOutput(os.Stderr) Usage: "Registry password",
logrus.AddHook(logrusStack.StandardHook()) EnvVars: []string{"REGISTRY_PASSWORD"},
} Destination: &RegistryUsername,
return nil
} }
// SSHFailMsg is a hopefully helpful SSH failure message
var SSHFailMsg = `
Woops, Abra is unable to connect to connect to %s.
Here are a few tips for debugging your local SSH config. Abra uses plain 'ol
SSH to make connections to servers, so if your SSH config is working, Abra is
working.
In the first place, Abra will always try to read your Docker context connection
string for SSH connection details. You can view your server context configs
with the following command. Are they correct?
abra server ls
Is your ssh-agent running? You can start it by running the following command:
eval "$(ssh-agent)"
If your SSH private key loaded? You can check by running the following command:
ssh-add -L
If, you can add it with:
ssh-add ~/.ssh/<private-key-part>
If you are using a non-default public/private key, you can configure this in
your ~/.ssh/config file which Abra will read in order to figure out connection
details:
Host foo.coopcloud.tech
Hostname foo.coopcloud.tech
User bar
Port 12345
IdentityFile ~/.ssh/bar@foo.coopcloud.tech
If you're only using password authentication, you can use the following config:
Host foo.coopcloud.tech
Hostname foo.coopcloud.tech
User bar
Port 12345
PreferredAuthentications=password
PubkeyAuthentication=no
Good luck!
`

View File

@ -4,19 +4,15 @@ import (
"fmt" "fmt"
"path" "path"
"coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/jsontable"
"coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/recipe"
recipePkg "coopcloud.tech/abra/pkg/recipe" recipePkg "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/runtime"
"coopcloud.tech/abra/pkg/secret" "coopcloud.tech/abra/pkg/secret"
"coopcloud.tech/abra/pkg/ssh"
"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" "github.com/urfave/cli/v2"
) )
// AppSecrets represents all app secrest // AppSecrets represents all app secrest
@ -26,15 +22,15 @@ type AppSecrets map[string]string
var RecipeName string var RecipeName string
// createSecrets creates all secrets for a new app. // createSecrets creates all secrets for a new app.
func createSecrets(cl *dockerClient.Client, sanitisedAppName string) (AppSecrets, error) { func createSecrets(sanitisedAppName string) (AppSecrets, error) {
appEnvPath := path.Join(config.ABRA_DIR, "servers", NewAppServer, fmt.Sprintf("%s.env", Domain)) appEnvPath := path.Join(config.ABRA_DIR, "servers", NewAppServer, fmt.Sprintf("%s.env", sanitisedAppName))
appEnv, err := config.ReadEnv(appEnvPath) appEnv, err := config.ReadEnv(appEnvPath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
secretEnvVars := secret.ReadSecretEnvVars(appEnv) secretEnvVars := secret.ReadSecretEnvVars(appEnv)
secrets, err := secret.GenerateSecrets(cl, secretEnvVars, sanitisedAppName, NewAppServer) secrets, err := secret.GenerateSecrets(secretEnvVars, sanitisedAppName, NewAppServer)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -42,7 +38,7 @@ func createSecrets(cl *dockerClient.Client, sanitisedAppName string) (AppSecrets
if Pass { if Pass {
for secretName := range secrets { for secretName := range secrets {
secretValue := secrets[secretName] secretValue := secrets[secretName]
if err := secret.PassInsertSecret(secretValue, secretName, Domain, NewAppServer); err != nil { if err := secret.PassInsertSecret(secretValue, secretName, sanitisedAppName, NewAppServer); err != nil {
return nil, err return nil, err
} }
} }
@ -69,31 +65,6 @@ func ensureDomainFlag(recipe recipe.Recipe, server string) error {
return nil 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 !Secrets && !NoInput {
prompt := &survey.Confirm{
Message: "Generate app secrets?",
}
if err := survey.AskOne(prompt, &Secrets); err != nil {
return err
}
}
return nil
}
// ensureServerFlag checks if the server flag was used. if not, asks the user for it. // ensureServerFlag checks if the server flag was used. if not, asks the user for it.
func ensureServerFlag() error { func ensureServerFlag() error {
servers, err := config.GetServers() servers, err := config.GetServers()
@ -118,9 +89,28 @@ func ensureServerFlag() error {
return nil 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: 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 // NewAction is the new app creation logic
func NewAction(c *cli.Context) error { func NewAction(c *cli.Context) error {
recipe := ValidateRecipeWithPrompt(c, runtime.WithEnsureRecipeLatest(false)) recipe := ValidateRecipeWithPrompt(c)
if err := recipePkg.EnsureUpToDate(recipe.Name); err != nil { if err := recipePkg.EnsureUpToDate(recipe.Name); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
@ -134,45 +124,48 @@ func NewAction(c *cli.Context) error {
logrus.Fatal(err) logrus.Fatal(err)
} }
sanitisedAppName := config.SanitiseAppName(Domain) if err := ensureAppNameFlag(); err != nil {
logrus.Debugf("%s sanitised as %s for new app", Domain, sanitisedAppName)
if err := config.TemplateAppEnvSample(recipe.Name, Domain, NewAppServer, Domain); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if err := promptForSecrets(Domain); err != nil { 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); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
cl, err := client.New(NewAppServer)
if err != nil {
logrus.Fatal(err)
}
var secrets AppSecrets
var secretTable *jsontable.JSONTable
if Secrets { if Secrets {
secrets, err := createSecrets(cl, sanitisedAppName) if err := ssh.EnsureHostKey(NewAppServer); err != nil {
logrus.Fatal(err)
}
secrets, err := createSecrets(sanitisedAppName)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
secretCols := []string{"Name", "Value"} secretCols := []string{"Name", "Value"}
secretTable = formatter.CreateTable(secretCols) secretTable := formatter.CreateTable(secretCols)
for secret := range secrets { for secret := range secrets {
secretTable.Append([]string{secret, secrets[secret]}) secretTable.Append([]string{secret, secrets[secret]})
} }
if len(secrets) > 0 {
defer secretTable.Render()
}
} }
if NewAppServer == "default" { if NewAppServer == "default" {
NewAppServer = "local" NewAppServer = "local"
} }
tableCol := []string{"server", "recipe", "domain"} tableCol := []string{"server", "type", "domain", "app name"}
table := formatter.CreateTable(tableCol) table := formatter.CreateTable(tableCol)
table.Append([]string{NewAppServer, recipe.Name, Domain}) table.Append([]string{NewAppServer, recipe.Name, Domain, NewAppName})
fmt.Println("") fmt.Println("")
fmt.Println(fmt.Sprintf("A new %s app has been created! Here is an overview:", recipe.Name)) fmt.Println(fmt.Sprintf("A new %s app has been created! Here is an overview:", recipe.Name))
@ -180,19 +173,11 @@ func NewAction(c *cli.Context) error {
table.Render() table.Render()
fmt.Println("") fmt.Println("")
fmt.Println("You can configure this app by running the following:") fmt.Println("You can configure this app by running the following:")
fmt.Println(fmt.Sprintf("\n abra app config %s", Domain)) fmt.Println(fmt.Sprintf("\n abra app config %s", NewAppName))
fmt.Println("") fmt.Println("")
fmt.Println("You can deploy this app by running the following:") fmt.Println("You can deploy this app by running the following:")
fmt.Println(fmt.Sprintf("\n abra app deploy %s", Domain)) fmt.Println(fmt.Sprintf("\n abra app deploy %s", NewAppName))
fmt.Println("") 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 return nil
} }

View File

@ -3,15 +3,15 @@ package internal
import ( import (
"fmt" "fmt"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/recipe"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"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"
) )
// 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 You need to make a decision about what kind of an update this new recipe
@ -20,8 +20,6 @@ migration work or take care of some breaking changes? This can be signaled in
the version you specify on the recipe deploy label and is called a semantic the version you specify on the recipe deploy label and is called a semantic
version. version.
The latest published version is %s.
Here is a semver cheat sheet (more on https://semver.org): 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). major: new features/bug fixes, backwards incompatible (e.g 1.0.0 -> 2.0.0).
@ -36,7 +34,7 @@ Here is a semver cheat sheet (more on https://semver.org):
should also Just Work and is mostly to do with minor bug fixes should also Just Work and is mostly to do with minor bug fixes
and/or security patches. "nothing to worry about". and/or security patches. "nothing to worry about".
`, latestRelease) `)
var chosenBumpType string var chosenBumpType string
prompt := &survey.Select{ prompt := &survey.Select{
@ -96,7 +94,7 @@ func GetMainAppImage(recipe recipe.Recipe) (string, error) {
} }
path = reference.Path(img) path = reference.Path(img)
path = formatter.StripTagMeta(path) path = recipePkg.StripTagMeta(path)
return path, nil return path, nil
} }

View File

@ -9,25 +9,24 @@ import (
"coopcloud.tech/abra/pkg/app" "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/runtime" "coopcloud.tech/abra/pkg/ssh"
"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 // AppName is used for configuring app name programmatically
var AppName string var AppName string
// ValidateRecipe ensures the recipe arg is valid. // ValidateRecipe ensures the recipe arg is valid.
func ValidateRecipe(c *cli.Context, opts ...runtime.Option) recipe.Recipe { func ValidateRecipe(c *cli.Context) recipe.Recipe {
recipeName := c.Args().First() recipeName := c.Args().First()
conf := runtime.New(opts...)
if recipeName == "" { if recipeName == "" {
ShowSubcommandHelpAndError(c, errors.New("no recipe name provided")) ShowSubcommandHelpAndError(c, errors.New("no recipe name provided"))
} }
chosenRecipe, err := recipe.Get(recipeName, conf) chosenRecipe, err := recipe.Get(recipeName)
if err != nil { if err != nil {
if c.Command.Name == "generate" { if c.Command.Name == "generate" {
if strings.Contains(err.Error(), "missing a compose") { if strings.Contains(err.Error(), "missing a compose") {
@ -35,15 +34,6 @@ func ValidateRecipe(c *cli.Context, opts ...runtime.Option) recipe.Recipe {
} }
logrus.Warn(err) logrus.Warn(err)
} else { } 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)
}
}
if conf.EnsureRecipeLatest {
if err := recipe.EnsureLatest(recipeName, conf); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} }
@ -55,9 +45,8 @@ func ValidateRecipe(c *cli.Context, opts ...runtime.Option) recipe.Recipe {
// ValidateRecipeWithPrompt ensures a recipe argument is present before // ValidateRecipeWithPrompt ensures a recipe argument is present before
// validating, asking for input if required. // validating, asking for input if required.
func ValidateRecipeWithPrompt(c *cli.Context, opts ...runtime.Option) recipe.Recipe { func ValidateRecipeWithPrompt(c *cli.Context) recipe.Recipe {
recipeName := c.Args().First() recipeName := c.Args().First()
conf := runtime.New(opts...)
if recipeName == "" && !NoInput { if recipeName == "" && !NoInput {
var recipes []string var recipes []string
@ -105,26 +94,19 @@ func ValidateRecipeWithPrompt(c *cli.Context, opts ...runtime.Option) recipe.Rec
ShowSubcommandHelpAndError(c, errors.New("no recipe name provided")) ShowSubcommandHelpAndError(c, errors.New("no recipe name provided"))
} }
chosenRecipe, err := recipe.Get(recipeName, conf) chosenRecipe, err := recipe.Get(recipeName)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if conf.EnsureRecipeLatest {
if err := recipe.EnsureLatest(recipeName, conf); err != nil {
logrus.Fatal(err)
}
}
logrus.Debugf("validated %s as recipe argument", recipeName) logrus.Debugf("validated %s as recipe argument", recipeName)
return chosenRecipe return chosenRecipe
} }
// ValidateApp ensures the app name arg is valid. // ValidateApp ensures the app name arg is valid.
func ValidateApp(c *cli.Context, opts ...runtime.Option) config.App { func ValidateApp(c *cli.Context) config.App {
appName := c.Args().First() appName := c.Args().First()
conf := runtime.New(opts...)
if AppName != "" { if AppName != "" {
appName = AppName appName = AppName
@ -140,7 +122,11 @@ func ValidateApp(c *cli.Context, opts ...runtime.Option) config.App {
logrus.Fatal(err) logrus.Fatal(err)
} }
if err := recipe.EnsureExists(app.Recipe, conf); err != nil { if err := recipe.EnsureExists(app.Type); err != nil {
logrus.Fatal(err)
}
if err := ssh.EnsureHostKey(app.Server); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -150,7 +136,7 @@ func ValidateApp(c *cli.Context, opts ...runtime.Option) config.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 {
@ -159,7 +145,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
} }
} }
@ -169,14 +155,14 @@ func ValidateDomain(c *cli.Context) string {
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
} }
@ -187,12 +173,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 {
@ -201,28 +187,17 @@ 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
}
}
if !matched {
ShowSubcommandHelpAndError(c, errors.New("server doesn't exist?"))
}
if serverName == "" { if serverName == "" {
ShowSubcommandHelpAndError(c, errors.New("no server provided")) ShowSubcommandHelpAndError(c, errors.New("no server provided"))
} }
logrus.Debugf("validated %s as server argument", serverName) logrus.Debugf("validated %s as server argument", serverName)
return serverName return serverName, nil
} }
// EnsureDNSProvider ensures a DNS provider is chosen. // EnsureDNSProvider ensures a DNS provider is chosen.
@ -394,7 +369,7 @@ func EnsureNewCapsulVPSFlags(c *cli.Context) error {
if err := survey.AskOne(prompt, &CapsulSSHKeys); err != nil { if err := survey.AskOne(prompt, &CapsulSSHKeys); err != nil {
return err return err
} }
CapsulSSHKeys = cli.StringSlice(strings.Split(sshKeys, ",")) CapsulSSHKeys = *cli.NewStringSlice(strings.Split(sshKeys, ",")...)
} }
if CapsulAPIToken == "" && !NoInput { if CapsulAPIToken == "" && !NoInput {
@ -473,7 +448,7 @@ func EnsureNewHetznerCloudVPSFlags(c *cli.Context) error {
if err := survey.AskOne(prompt, &sshKeys); err != nil { if err := survey.AskOne(prompt, &sshKeys); err != nil {
return err return err
} }
HetznerCloudSSHKeys = cli.StringSlice(strings.Split(sshKeys, ",")) HetznerCloudSSHKeys = *cli.NewStringSlice(strings.Split(sshKeys, ",")...)
} }
if !NoInput { if !NoInput {

View File

@ -1,40 +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 local copies",
Aliases: []string{"f"},
ArgsUsage: "[<recipe>]",
Description: "Fetchs all recipes without arguments.",
Flags: []cli.Flag{
internal.DebugFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.RecipeNameComplete,
Action: func(c *cli.Context) error {
recipeName := c.Args().First()
if recipeName != "" {
internal.ValidateRecipe(c)
return nil // ValidateRecipe ensures latest checkout
}
repos, err := recipe.ReadReposMetadata()
if err != nil {
logrus.Fatal(err)
}
if err := recipe.UpdateRepositories(repos, recipeName); err != nil {
logrus.Fatal(err)
}
return nil
},
}

View File

@ -9,19 +9,15 @@ import (
"coopcloud.tech/abra/pkg/lint" "coopcloud.tech/abra/pkg/lint"
recipePkg "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 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{ Flags: []cli.Flag{internal.OnlyErrorFlag},
internal.DebugFlag,
internal.OnlyErrorFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.RecipeNameComplete, BashComplete: autocomplete.RecipeNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipe(c) recipe := internal.ValidateRecipe(c)
@ -30,62 +26,38 @@ var recipeLintCommand = cli.Command{
logrus.Fatal(err) logrus.Fatal(err)
} }
tableCol := []string{"ref", "rule", "severity", "satisfied", "skipped", "resolve"} tableCol := []string{"ref", "rule", "satisfied", "severity", "resolve"}
table := formatter.CreateTable(tableCol) table := formatter.CreateTable(tableCol)
hasError := false hasError := false
bar := formatter.CreateProgressbar(-1, "running recipe lint rules...") bar := formatter.CreateProgressbar(-1, "running recipe lint rules...")
for level := range lint.LintRules { for level := range lint.LintRules {
for _, rule := range lint.LintRules[level] { for _, rule := range lint.LintRules[level] {
if internal.OnlyErrors && rule.Level != "error" { ok, err := rule.Function(recipe)
logrus.Debugf("skipping %s, does not have level \"error\"", rule.Ref) if err != nil {
continue logrus.Warn(err)
} }
skipped := false if !ok && rule.Level == "error" {
if rule.Skip(recipe) { hasError = true
skipped = true
} }
skippedOutput := "-" var result string
if skipped { if ok {
skippedOutput = "yes" result = "yes"
} else {
result = "NO"
} }
satisfied := false if internal.OnlyErrors {
if !skipped {
ok, err := rule.Function(recipe)
if err != nil {
logrus.Warn(err)
}
if !ok && rule.Level == "error" { if !ok && rule.Level == "error" {
hasError = true table.Append([]string{rule.Ref, rule.Description, result, rule.Level, rule.HowToResolve})
} bar.Add(1)
if ok {
satisfied = true
} }
} else {
table.Append([]string{rule.Ref, rule.Description, result, rule.Level, rule.HowToResolve})
bar.Add(1)
} }
satisfiedOutput := "yes"
if !satisfied {
satisfiedOutput = "NO"
if skipped {
satisfiedOutput = "-"
}
}
table.Append([]string{
rule.Ref,
rule.Description,
rule.Level,
satisfiedOutput,
skippedOutput,
rule.HowToResolve,
})
bar.Add(1)
} }
} }

View File

@ -2,36 +2,42 @@ package recipe
import ( import (
"fmt" "fmt"
"path"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/formatter"
gitPkg "coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/recipe" "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 pattern string
var patternFlag = &cli.StringFlag{ var patternFlag = &cli.StringFlag{
Name: "pattern, p", Name: "pattern",
Value: "", Value: "",
Aliases: []string{"p"},
Usage: "Simple string to filter recipes", Usage: "Simple string to filter recipes",
Destination: &pattern, Destination: &pattern,
} }
var recipeListCommand = cli.Command{ 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{ Flags: []cli.Flag{
internal.DebugFlag,
internal.MachineReadableFlag,
patternFlag, patternFlag,
}, },
Before: internal.SubCommandBefore,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
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
}
catl, err := recipe.ReadRecipeCatalogue() catl, err := recipe.ReadRecipeCatalogue()
if err != nil { if err != nil {
logrus.Fatal(err.Error()) logrus.Fatal(err.Error())
@ -67,14 +73,10 @@ var recipeListCommand = cli.Command{
} }
} }
table.SetCaption(true, fmt.Sprintf("total recipes: %v", len))
if table.NumLines() > 0 { if table.NumLines() > 0 {
if internal.MachineReadable { table.Render()
table.SetCaption(false, "")
table.JSONRender()
} else {
table.SetCaption(true, fmt.Sprintf("total recipes: %v", len))
table.Render()
}
} }
return nil return nil

View File

@ -13,7 +13,7 @@ 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 // recipeMetadata is the recipe metadata for the README.md
@ -30,20 +30,15 @@ type recipeMetadata struct {
SSO string SSO string
} }
var recipeNewCommand = cli.Command{ var recipeNewCommand = &cli.Command{
Name: "new", Name: "new",
Aliases: []string{"n"},
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
},
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
@ -111,7 +106,7 @@ In order to share your recipe, you can upload it the git repository to:
If you're not sure how to do that, come chat with us: If you're not sure how to do that, come chat with us:
https://docs.coopcloud.tech/intro/contact https://docs.coopcloud.tech/contact
See "abra recipe -h" for additional recipe maintainer commands. See "abra recipe -h" for additional recipe maintainer commands.

View File

@ -1,34 +1,32 @@
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", 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 config 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 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 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 manner. Abra supports convenient automation for recipe maintainenace, see the
"abra recipe upgrade", "abra recipe sync" and "abra recipe release" commands. "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

@ -13,31 +13,31 @@ import (
gitPkg "coopcloud.tech/abra/pkg/git" 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/abra/pkg/runtime"
"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/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 version of a recipe. These versions are
Co-op Cloud recipe catalogue. These versions take the following form: then published on the Co-op Cloud recipe catalogue. These versions 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 a semantic version determined by the maintainer. And
"x.y.z" part is the image tag of the recipe "app" service (the main container the "x.y.z" part is the image tag of the recipe "app" service (the main
which contains the software to be used, by naming convention). 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 ("x.y.z") in order to maximise the chances that the nature of
recipe updates are properly communicated. I.e. developers of an app might recipe updates are properly communicated. I.e. developers of an app might
publish a minor version but that might lead to changes in the recipe which are publish a minor version but that might lead to changes in the recipe which are
@ -48,18 +48,15 @@ requires that you have permission to git push to these repositories and have
your SSH keys configured on your account. 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.PublishFlag,
}, },
Before: internal.SubCommandBefore,
BashComplete: autocomplete.RecipeNameComplete, BashComplete: autocomplete.RecipeNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipeWithPrompt(c, runtime.WithEnsureRecipeLatest(false)) recipe := internal.ValidateRecipeWithPrompt(c)
imagesTmp, err := getImageVersions(recipe) imagesTmp, err := getImageVersions(recipe)
if err != nil { if err != nil {
@ -143,7 +140,7 @@ func getImageVersions(recipe recipe.Recipe) (map[string]string, error) {
path := reference.Path(img) path := reference.Path(img)
path = formatter.StripTagMeta(path) path = recipePkg.StripTagMeta(path)
var tag string var tag string
switch img.(type) { switch img.(type) {
@ -242,10 +239,12 @@ func commitRelease(recipe recipe.Recipe, tag string) error {
} }
} }
msg := fmt.Sprintf("chore: publish %s release", tag) if internal.Publish {
repoPath := path.Join(config.RECIPES_DIR, recipe.Name) msg := fmt.Sprintf("chore: publish %s release", tag)
if err := gitPkg.Commit(repoPath, ".", msg, internal.Dry); err != nil { repoPath := path.Join(config.RECIPES_DIR, recipe.Name)
return err if err := gitPkg.Commit(repoPath, "compose.**yml", msg, internal.Dry); err != nil {
return err
}
} }
return nil return nil
@ -298,10 +297,13 @@ func pushRelease(recipe recipe.Recipe, tagString string) error {
if err := recipe.Push(internal.Dry); err != nil { if err := recipe.Push(internal.Dry); err != nil {
return err return err
} }
url := fmt.Sprintf("%s/%s/src/tag/%s", config.REPOS_BASE_URL, recipe.Name, tagString)
logrus.Infof("new release published: %s", url) if !internal.Dry {
} else { url := fmt.Sprintf("%s/%s/src/tag/%s", config.REPOS_BASE_URL, recipe.Name, tagString)
logrus.Info("no -p/--publish passed, not publishing") logrus.Infof("new release published: %s", url)
} else {
logrus.Info("dry run: no changes published")
}
} }
return nil return nil
@ -322,6 +324,12 @@ func createReleaseFromPreviousTag(tagString, mainAppVersion string, recipe recip
} }
var lastGitTag tagcmp.Tag var lastGitTag tagcmp.Tag
if tagString == "" {
if err := internal.PromptBumpType(tagString); err != nil {
return err
}
}
for _, tag := range tags { for _, tag := range tags {
parsed, err := tagcmp.Parse(tag) parsed, err := tagcmp.Parse(tag)
if err != nil { if err != nil {
@ -362,19 +370,13 @@ func createReleaseFromPreviousTag(tagString, mainAppVersion string, recipe recip
newTag.Major = strconv.Itoa(now + 1) 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 { if internal.Major || internal.Minor || internal.Patch {
newTag.Metadata = mainAppVersion newTag.Metadata = mainAppVersion
tagString = newTag.String() tagString = newTag.String()
} }
if lastGitTag.String() == tagString { if lastGitTag.String() == tagString {
logrus.Fatalf("latest git tag (%s) and synced label (%s) are the same?", lastGitTag, tagString) logrus.Fatalf("latest git tag (%s) and synced lable (%s) are the same?", lastGitTag, tagString)
} }
if !internal.NoInput { if !internal.NoInput {
@ -393,15 +395,15 @@ func createReleaseFromPreviousTag(tagString, mainAppVersion string, recipe recip
} }
if err := commitRelease(recipe, tagString); err != nil { if err := commitRelease(recipe, tagString); err != nil {
logrus.Fatalf("failed to commit changes: %s", err.Error()) logrus.Fatal(err)
} }
if err := tagRelease(tagString, repo); err != nil { if err := tagRelease(tagString, repo); err != nil {
logrus.Fatalf("failed to tag release: %s", err.Error()) logrus.Fatal(err)
} }
if err := pushRelease(recipe, tagString); err != nil { if err := pushRelease(recipe, tagString); err != nil {
logrus.Fatalf("failed to publish new release: %s", err.Error()) logrus.Fatal(err)
} }
return nil return nil

View File

@ -8,32 +8,28 @@ import (
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/runtime"
"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",
Aliases: []string{"s"},
Usage: "Sync recipe version label", Usage: "Sync recipe version label",
Aliases: []string{"s"},
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>
@ -42,7 +38,7 @@ auto-generate it for you. The <recipe> configuration will be updated on the
local file system. local file system.
`, `,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipeWithPrompt(c, runtime.WithEnsureRecipeLatest(false)) recipe := internal.ValidateRecipeWithPrompt(c)
mainApp, err := internal.GetMainAppImage(recipe) mainApp, err := internal.GetMainAppImage(recipe)
if err != nil { if err != nil {
@ -96,8 +92,7 @@ likely to change.
} }
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)
} }
} }

View File

@ -12,13 +12,12 @@ import (
"coopcloud.tech/abra/pkg/autocomplete" "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"
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/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
type imgPin struct { type imgPin struct {
@ -26,14 +25,14 @@ type imgPin struct {
version tagcmp.Tag version tagcmp.Tag
} }
var recipeUpgradeCommand = cli.Command{ 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
@ -46,25 +45,18 @@ interface.
You may invoke this command in "wizard" mode and be prompted for input: You may invoke this command in "wizard" mode and be prompted for input:
abra recipe upgrade abra recipe upgrade
`, `,
BashComplete: autocomplete.RecipeNameComplete, BashComplete: autocomplete.RecipeNameComplete,
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.AllTagsFlag,
}, },
Before: internal.SubCommandBefore,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipeWithPrompt(c) recipe := internal.ValidateRecipeWithPrompt(c)
if err := recipePkg.EnsureUpToDate(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
@ -116,14 +108,14 @@ You may invoke this command in "wizard" mode and be prompted for input:
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)
} }
image := reference.Path(img)
logrus.Debugf("retrieved %s from remote registry for %s", regVersions, image) logrus.Debugf("retrieved %s from remote registry for %s", regVersions, image)
image = formatter.StripTagMeta(image)
image = recipePkg.StripTagMeta(image)
switch img.(type) { switch img.(type) {
case reference.NamedTagged: case reference.NamedTagged:
@ -145,7 +137,7 @@ You may invoke this command in "wizard" mode and be prompted for input:
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
} }
@ -159,8 +151,8 @@ You may invoke this command in "wizard" mode and be prompted for input:
sort.Sort(tagcmp.ByTagDesc(compatible)) sort.Sort(tagcmp.ByTagDesc(compatible))
if len(compatible) == 0 && !internal.AllTags { if len(compatible) == 0 {
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
} }
@ -227,15 +219,13 @@ You may invoke this command in "wizard" mode and be prompted for input:
} }
} 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, image: %s, tag: %s)", service.Name, image, 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{"skip"}
for _, regVersion := range regVersions { for _, regVersion := range regVersions {
compatibleStrings = append(compatibleStrings, regVersion) compatibleStrings = append(compatibleStrings, regVersion.Name)
} }
} }

View File

@ -1,27 +1,33 @@
package recipe package recipe
import ( import (
"fmt"
"path"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/formatter"
gitPkg "coopcloud.tech/abra/pkg/git"
recipePkg "coopcloud.tech/abra/pkg/recipe" recipePkg "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/runtime"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var recipeVersionCommand = cli.Command{ 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>",
Flags: []cli.Flag{
internal.DebugFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.RecipeNameComplete, BashComplete: autocomplete.RecipeNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipe(c, runtime.WithEnsureRecipeLatest(false)) recipe := internal.ValidateRecipe(c)
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
}
catalogue, err := recipePkg.ReadRecipeCatalogue() catalogue, err := recipePkg.ReadRecipeCatalogue()
if err != nil { if err != nil {
@ -33,13 +39,13 @@ var recipeVersionCommand = cli.Command{
logrus.Fatalf("%s recipe doesn't exist?", recipe.Name) logrus.Fatalf("%s recipe doesn't exist?", recipe.Name)
} }
tableCol := []string{"Version", "Service", "Image", "Tag"} tableCol := []string{"Version", "Service", "Image", "Tag", "Digest"}
table := formatter.CreateTable(tableCol) table := formatter.CreateTable(tableCol)
for i := len(recipeMeta.Versions) - 1; i >= 0; i-- { for i := len(recipeMeta.Versions) - 1; i >= 0; i-- {
for tag, meta := range recipeMeta.Versions[i] { for tag, meta := range recipeMeta.Versions[i] {
for service, serviceMeta := range meta { for service, serviceMeta := range meta {
table.Append([]string{tag, service, serviceMeta.Image, serviceMeta.Tag}) table.Append([]string{tag, service, serviceMeta.Image, serviceMeta.Tag, serviceMeta.Digest})
} }
} }
} }

View File

@ -1,7 +1,6 @@
package record package record
import ( import (
"context"
"fmt" "fmt"
"strconv" "strconv"
@ -10,23 +9,21 @@ import (
"coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/formatter"
"github.com/libdns/gandi" "github.com/libdns/gandi"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
// RecordListCommand lists domains. // RecordListCommand lists domains.
var RecordListCommand = cli.Command{ var RecordListCommand = &cli.Command{
Name: "list", Name: "list",
Usage: "List domain name records", Usage: "List domain name records",
Aliases: []string{"ls"}, Aliases: []string{"ls"},
ArgsUsage: "<zone>", ArgsUsage: "<zone>",
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.DebugFlag,
internal.DNSProviderFlag, internal.DNSProviderFlag,
}, },
Before: internal.SubCommandBefore,
Description: ` Description: `
List all domain name records managed by a 3rd party provider for a specific This command lists all domain name records managed by a 3rd party provider for
zone. a specific zone.
You must specify a zone (e.g. example.com) under which your domain name records 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. are listed. This zone must already be created on your provider account.
@ -52,7 +49,7 @@ are listed. This zone must already be created on your provider account.
logrus.Fatalf("%s is not a supported DNS provider", internal.DNSProvider) logrus.Fatalf("%s is not a supported DNS provider", internal.DNSProvider)
} }
records, err := provider.GetRecords(context.Background(), zone) records, err := provider.GetRecords(c.Context, zone)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }

View File

@ -1,7 +1,6 @@
package record package record
import ( import (
"context"
"fmt" "fmt"
"strconv" "strconv"
@ -12,28 +11,26 @@ import (
"github.com/libdns/gandi" "github.com/libdns/gandi"
"github.com/libdns/libdns" "github.com/libdns/libdns"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
// RecordNewCommand creates a new domain name record. // RecordNewCommand creates a new domain name record.
var RecordNewCommand = cli.Command{ var RecordNewCommand = &cli.Command{
Name: "new", Name: "new",
Usage: "Create a new domain record", Usage: "Create a new domain record",
Aliases: []string{"n"}, Aliases: []string{"n"},
ArgsUsage: "<zone>", ArgsUsage: "<zone>",
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.DNSProviderFlag, internal.DNSProviderFlag,
internal.DNSTypeFlag, internal.DNSTypeFlag,
internal.DNSNameFlag, internal.DNSNameFlag,
internal.DNSValueFlag, internal.DNSValueFlag,
internal.DNSTTLFlag, internal.DNSTTLFlag,
internal.DNSPriorityFlag, internal.DNSPriorityFlag,
internal.AutoDNSRecordFlag,
}, },
Before: internal.SubCommandBefore,
Description: ` Description: `
Create a new domain name record for a specific zone. 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 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. are listed. This zone must already be created on your provider account.
@ -42,9 +39,16 @@ Example:
abra record new foo.com -p gandi -t A -n myapp -v 192.168.178.44 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: Typically, you need two records, an A record which points at the zone (@.) and
a wildcard record for your apps (*.). Pass "--auto" to have Abra automatically
set this up.
abra record new --auto foo.com -p gandi -v 192.168.178.44
You may also invoke this command in "wizard" mode and be prompted for input
abra record new abra record new
`, `,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
zone, err := internal.EnsureZoneArgument(c) zone, err := internal.EnsureZoneArgument(c)
@ -67,6 +71,25 @@ You may also invoke this command in "wizard" mode and be prompted for input:
logrus.Fatalf("%s is not a supported DNS provider", internal.DNSProvider) logrus.Fatalf("%s is not a supported DNS provider", internal.DNSProvider)
} }
if internal.AutoDNSRecord {
ipv4, err := dns.EnsureIPv4(zone)
if err != nil {
logrus.Debugf("no ipv4 associated with %s, prompting for input", zone)
if err := internal.EnsureDNSValueFlag(c); err != nil {
logrus.Fatal(err)
}
ipv4 = internal.DNSValue
}
logrus.Infof("automatically configuring @./*. A records for %s for %s (--auto)", zone, ipv4)
if err := autoConfigure(c, &provider, zone, ipv4); err != nil {
logrus.Fatal(err)
}
return nil
}
if err := internal.EnsureDNSTypeFlag(c); err != nil { if err := internal.EnsureDNSTypeFlag(c); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -95,7 +118,7 @@ You may also invoke this command in "wizard" mode and be prompted for input:
record.Priority = internal.DNSPriority record.Priority = internal.DNSPriority
} }
records, err := provider.GetRecords(context.Background(), zone) records, err := provider.GetRecords(c.Context, zone)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -109,7 +132,7 @@ You may also invoke this command in "wizard" mode and be prompted for input:
} }
createdRecords, err := provider.SetRecords( createdRecords, err := provider.SetRecords(
context.Background(), c.Context,
zone, zone,
[]libdns.Record{record}, []libdns.Record{record},
) )
@ -146,3 +169,84 @@ You may also invoke this command in "wizard" mode and be prompted for input:
return nil return nil
}, },
} }
func autoConfigure(c *cli.Context, provider *gandi.Provider, zone, ipv4 string) error {
ttl, err := dns.GetTTL(internal.DNSTTL)
if err != nil {
return err
}
atRecord := libdns.Record{
Type: "A",
Name: "@",
Value: ipv4,
TTL: ttl,
}
wildcardRecord := libdns.Record{
Type: "A",
Name: "*",
Value: ipv4,
TTL: ttl,
}
records := []libdns.Record{atRecord, wildcardRecord}
tableCol := []string{"type", "name", "value", "TTL", "priority"}
table := formatter.CreateTable(tableCol)
for _, record := range records {
existingRecords, err := provider.GetRecords(c.Context, zone)
if err != nil {
return err
}
discovered := false
for _, existingRecord := range existingRecords {
if existingRecord.Type == record.Type &&
existingRecord.Name == record.Name &&
existingRecord.Value == record.Value {
logrus.Warnf("%s record: %s %s for %s already exists?", record.Type, record.Name, record.Value, zone)
discovered = true
}
}
if discovered {
continue
}
createdRecords, err := provider.SetRecords(
c.Context,
zone,
[]libdns.Record{record},
)
if err != nil {
return err
}
if len(createdRecords) == 0 {
return fmt.Errorf("provider library reports that no record was created?")
}
createdRecord := createdRecords[0]
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),
})
}
if table.NumLines() > 0 {
table.Render()
}
return nil
}

View File

@ -1,19 +1,19 @@
package record package record
import ( import (
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
// RecordCommand supports managing DNS entries. // RecordCommand supports managing DNS entries.
var RecordCommand = cli.Command{ var RecordCommand = &cli.Command{
Name: "record", Name: "record",
Usage: "Manage domain name records", Usage: "Manage domain name records",
Aliases: []string{"rc"}, Aliases: []string{"rc"},
ArgsUsage: "<record>", ArgsUsage: "<record>",
Description: ` Description: `
Manage domain name records via 3rd party providers such as Gandi DNS. It This command supports managing domain name records via 3rd party providers such
supports listing, creating and removing all types of records that you might as Gandi DNS. It supports listing, creating and removing all types of records
need for managing Co-op Cloud apps. that you might need for managing Co-op Cloud apps.
The following providers are supported: The following providers are supported:
@ -28,8 +28,9 @@ library documentation for more. It supports many existing providers and allows
to implement new provider support easily. to implement new provider support easily.
https://pkg.go.dev/github.com/libdns/libdns https://pkg.go.dev/github.com/libdns/libdns
`, `,
Subcommands: []cli.Command{ Subcommands: []*cli.Command{
RecordListCommand, RecordListCommand,
RecordNewCommand, RecordNewCommand,
RecordRemoveCommand, RecordRemoveCommand,

View File

@ -1,7 +1,6 @@
package record package record
import ( import (
"context"
"fmt" "fmt"
"strconv" "strconv"
@ -12,25 +11,22 @@ import (
"github.com/libdns/gandi" "github.com/libdns/gandi"
"github.com/libdns/libdns" "github.com/libdns/libdns"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
// RecordRemoveCommand lists domains. // RecordRemoveCommand lists domains.
var RecordRemoveCommand = cli.Command{ var RecordRemoveCommand = &cli.Command{
Name: "remove", Name: "remove",
Usage: "Remove a domain name record", Usage: "Remove a domain name record",
Aliases: []string{"rm"}, Aliases: []string{"rm"},
ArgsUsage: "<zone>", ArgsUsage: "<zone>",
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.DNSProviderFlag, internal.DNSProviderFlag,
internal.DNSTypeFlag, internal.DNSTypeFlag,
internal.DNSNameFlag, internal.DNSNameFlag,
}, },
Before: internal.SubCommandBefore,
Description: ` Description: `
Remove a domain name record for a specific zone. 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 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 for deletion. You must specify a zone (e.g. example.com) under which your
@ -41,7 +37,7 @@ Example:
abra record remove foo.com -p gandi -t A -n myapp 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: You may also invoke this command in "wizard" mode and be prompted for input
abra record rm abra record rm
`, `,
@ -74,7 +70,7 @@ You may also invoke this command in "wizard" mode and be prompted for input:
logrus.Fatal(err) logrus.Fatal(err)
} }
records, err := provider.GetRecords(context.Background(), zone) records, err := provider.GetRecords(c.Context, zone)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -124,7 +120,7 @@ You may also invoke this command in "wizard" mode and be prompted for input:
} }
} }
_, err = provider.DeleteRecords(context.Background(), zone, []libdns.Record{toDelete}) _, err = provider.DeleteRecords(c.Context, zone, []libdns.Record{toDelete})
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }

View File

@ -2,8 +2,13 @@ package server
import ( import (
"errors" "errors"
"fmt"
"os" "os"
"os/exec"
"os/user"
"path"
"path/filepath" "path/filepath"
"strings"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
@ -11,48 +16,164 @@ import (
contextPkg "coopcloud.tech/abra/pkg/context" contextPkg "coopcloud.tech/abra/pkg/context"
"coopcloud.tech/abra/pkg/dns" "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.SERVERS_DIR, 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")
}
for _, exe := range []string{"wget", "bash"} {
exists, err := ensureLocalExecutable(exe)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("%s missing, please install it", exe)
}
}
cmd := exec.Command("bash", "-c", "wget -O- 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 {
exists, err := ensureLocalExecutable("docker")
if err != nil {
return err
}
if !exists {
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 +197,187 @@ 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 {
exists, err := ensureRemoteExecutable("docker", sshCl)
if err != nil {
return err
}
if !exists {
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")
}
exes := []string{"wget", "bash"}
if askSudoPass {
exes = append(exes, "ssh-askpass")
}
for _, exe := range exes {
exists, err := ensureRemoteExecutable(exe, sshCl)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("%s missing on remote, please install it", exe)
}
}
var sudoPass string
if askSudoPass {
cmd := "wget -O- https://get.docker.com | bash"
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 sudoPass == "" {
return fmt.Errorf("missing sudo password but requested --ask-sudo-pass?")
}
logrus.Warn("installing docker, this could take some time...")
if err := ssh.RunSudoCmd(cmd, sudoPass, sshCl); err != nil {
fmt.Print(fmt.Sprintf(`
Abra was unable to bootstrap Docker, see below for logs:
%s
If nothing works, you try running the Docker install script manually on your server:
wget -O- https://get.docker.com | bash
`, string(err.Error())))
logrus.Fatal("Process exited with status 1")
}
logrus.Infof("docker is installed on %s", domainName)
remoteUser := sshCl.SSHClient.Conn.User()
logrus.Infof("adding %s to docker group", remoteUser)
permsCmd := fmt.Sprintf("sudo usermod -aG docker %s", remoteUser)
if err := ssh.RunSudoCmd(permsCmd, sudoPass, sshCl); err != nil {
return err
}
} else {
cmd := "wget -O- https://get.docker.com | bash"
logrus.Debugf("running %s on %s now without sudo password", cmd, domainName)
logrus.Warn("installing docker, this could take some time...")
if out, err := sshCl.Exec(cmd); err != nil {
fmt.Print(fmt.Sprintf(`
Abra was unable to bootstrap Docker, see below for logs:
%s
This could be due to a number of things but one of the most common is that your
server user account does not have sudo access, and if it does, you need to pass
"--ask-sudo-pass" in order to supply Abra with your password.
If nothing works, you try running the Docker install script manually on your server:
wget -O- https://get.docker.com | bash
`, string(out)))
logrus.Fatal(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") ||
strings.Contains(err.Error(), "must specify a listening address") {
logrus.Infof("swarm mode already initialised on %s", domainName)
} else {
return err
}
} 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 {
ipv4, err := dns.EnsureIPv4(domainName)
if err != nil {
return err
}
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") ||
strings.Contains(err.Error(), "must specify a listening address") {
logrus.Infof("swarm mode already initialised on %s", domainName)
} else {
return err
}
} 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,98 +385,195 @@ 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) {
logrus.Info(fmt.Sprintf("-t/--traefik specified, automatically deploying traefik to %s", internal.NewAppServer))
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)
if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
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 { if err := createServerDir(domainName); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
hostConfig, err := sshPkg.GetHostConfig(domainName) if err := newContext(c, domainName, username, port); err != nil {
logrus.Fatal(err)
}
cl, err := newClient(c, domainName)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if err := newContext(c, domainName, hostConfig.User, hostConfig.Port); err != nil { if provision {
logrus.Fatal(err) 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)
}
} }
logrus.Infof("attempting to create client for %s", domainName) if _, err := cl.Info(c.Context); err != nil {
if _, err := client.New(domainName); err != nil {
cleanUp(domainName) cleanUp(domainName)
logrus.Debugf("failed to construct client for %s, saw %s", domainName, err.Error()) logrus.Fatalf("couldn't make a remote docker connection to %s? use --provision/-p to attempt to install", domainName)
logrus.Fatal(sshPkg.Fatal(domainName, err))
} }
logrus.Infof("%s added", domainName) if traefik {
if err := deployTraefik(c, cl, domainName); err != nil {
logrus.Fatal(err)
}
}
return nil return nil
}, },
} }
// ensureLocalExecutable ensures that an executable is present on the local machine
func ensureLocalExecutable(exe string) (bool, error) {
out, err := exec.Command("which", exe).Output()
if err != nil {
return false, err
}
return string(out) != "", nil
}
// ensureRemoteExecutable ensures that an executable is present on a remote machine
func ensureRemoteExecutable(exe string, sshCl *ssh.Client) (bool, error) {
out, err := sshCl.Exec(fmt.Sprintf("which %s", exe))
if err != nil && string(out) != "" {
return false, err
}
return string(out) != "", nil
}

View File

@ -3,33 +3,20 @@ package server
import ( import (
"strings" "strings"
"coopcloud.tech/abra/cli/internal"
"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" "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{
Name: "list",
var problemsFilterFlag = &cli.BoolFlag{ Aliases: []string{"ls"},
Name: "problems, p", Usage: "List managed servers",
Usage: "Show only servers with potential connection problems", ArgsUsage: " ",
Destination: &problemsFilter, HideHelp: true,
}
var serverListCommand = cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "List managed servers",
Flags: []cli.Flag{
problemsFilterFlag,
internal.DebugFlag,
internal.MachineReadableFlag,
},
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()
@ -39,11 +26,8 @@ var serverListCommand = cli.Command{
tableColumns := []string{"name", "host", "user", "port"} tableColumns := []string{"name", "host", "user", "port"}
table := formatter.CreateTable(tableColumns) table := formatter.CreateTable(tableColumns)
if internal.MachineReadable { defer table.Render()
defer table.JSONRender()
} else {
defer table.Render()
}
serverNames, err := config.ReadServerNames() serverNames, err := config.ReadServerNames()
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
@ -57,7 +41,6 @@ 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 {
@ -66,7 +49,6 @@ var serverListCommand = cli.Command{
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"}
@ -74,14 +56,7 @@ var serverListCommand = cli.Command{
row = []string{serverName, "unknown", "unknown", "unknown"} row = []string{serverName, "unknown", "unknown", "unknown"}
} }
} }
table.Append(row)
if problemsFilter {
if row[1] == "unknown" {
table.Append(row)
}
} else {
table.Append(row)
}
} }
return nil return nil

View File

@ -1,7 +1,6 @@
package server package server
import ( import (
"context"
"fmt" "fmt"
"strings" "strings"
@ -11,7 +10,7 @@ import (
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
"github.com/hetznercloud/hcloud-go/hcloud" "github.com/hetznercloud/hcloud-go/hcloud"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
func newHetznerCloudVPS(c *cli.Context) error { func newHetznerCloudVPS(c *cli.Context) error {
@ -28,7 +27,7 @@ func newHetznerCloudVPS(c *cli.Context) error {
continue continue
} }
sshKey, _, err := client.SSHKey.GetByName(context.Background(), sshKey) sshKey, _, err := client.SSHKey.GetByName(c.Context, sshKey)
if err != nil { if err != nil {
return err return err
} }
@ -73,7 +72,7 @@ func newHetznerCloudVPS(c *cli.Context) error {
logrus.Fatal("exiting as requested") logrus.Fatal("exiting as requested")
} }
res, _, err := client.Server.Create(context.Background(), serverOpts) res, _, err := client.Server.Create(c.Context, serverOpts)
if err != nil { if err != nil {
return err return err
} }
@ -99,10 +98,9 @@ You can access this new VPS via SSH using the following command:
ssh root@%s ssh root@%s
Please note, this server is not managed by Abra yet (i.e. "abra server ls" will 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 (manually not list this server)! You will need to assign a domain name record ("abra
or by using "abra record new") and add the server to your Abra configuration record new") and add the server to your Abra configuration ("abra server add")
("abra server add") to have a working server that you can deploy Co-op Cloud to have a working server that you can deploy Co-op Cloud apps to.
apps to.
When setting up domain name records, you probably want to set up the following When setting up domain name records, you probably want to set up the following
2 A records. This supports deploying apps to your root domain (e.g. 2 A records. This supports deploying apps to your root domain (e.g.
@ -111,6 +109,10 @@ bar.example.com).
@ 1800 IN A %s @ 1800 IN A %s
* 1800 IN A %s * 1800 IN A %s
"abra record new --auto" can help you do this quickly if you use a supported
DNS provider.
`, `,
internal.HetznerCloudName, ip, rootPassword, internal.HetznerCloudName, ip, rootPassword,
ip, ip, ip, ip, ip, ip,
@ -181,10 +183,9 @@ address. You can learn all about how to get SSH access to your new Capsul on:
%s/about-ssh %s/about-ssh
Please note, this server is not managed by Abra yet (i.e. "abra server ls" will 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 (manually not list this server)! You will need to assign a domain name record ("abra
or by using "abra record new") and add the server to your Abra configuration record new") and add the server to your Abra configuration ("abra server add")
("abra server add") to have a working server that you can deploy Co-op Cloud to have a working server that you can deploy Co-op Cloud apps to.
apps to.
When setting up domain name records, you probably want to set up the following When setting up domain name records, you probably want to set up the following
2 A records. This supports deploying apps to your root domain (e.g. 2 A records. This supports deploying apps to your root domain (e.g.
@ -193,17 +194,18 @@ bar.example.com).
@ 1800 IN A <your-capsul-ip> @ 1800 IN A <your-capsul-ip>
* 1800 IN A <your-capsul-ip> * 1800 IN A <your-capsul-ip>
`, internal.CapsulName, resp.ID, internal.CapsulInstanceURL)) `, internal.CapsulName, resp.ID, internal.CapsulInstanceURL))
return nil return nil
} }
var serverNewCommand = cli.Command{ var serverNewCommand = &cli.Command{
Name: "new", Name: "new",
Aliases: []string{"n"}, Aliases: []string{"n"},
Usage: "Create a new server using a 3rd party provider", Usage: "Create a new server using a 3rd party provider",
Description: ` Description: `
Create a new server via a 3rd party provider. This command creates a new server via a 3rd party provider.
The following providers are supported: The following providers are supported:
@ -217,10 +219,10 @@ You may invoke this command in "wizard" mode and be prompted for input:
API tokens are read from the environment if specified, e.g. API tokens are read from the environment if specified, e.g.
export HCLOUD_TOKEN=... export HCLOUD_TOKEN=...
Where "$provider_TOKEN" is the expected env var format.
`, `,
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.ServerProviderFlag, internal.ServerProviderFlag,
// Capsul // Capsul
@ -238,7 +240,6 @@ API tokens are read from the environment if specified, e.g.
internal.HetznerCloudLocationFlag, internal.HetznerCloudLocationFlag,
internal.HetznerCloudAPITokenFlag, internal.HetznerCloudAPITokenFlag,
}, },
Before: internal.SubCommandBefore,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
if err := internal.EnsureServerProvider(); err != nil { if err := internal.EnsureServerProvider(); err != nil {
logrus.Fatal(err) logrus.Fatal(err)

View File

@ -1,7 +1,6 @@
package server package server
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@ -13,12 +12,14 @@ import (
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
"github.com/hetznercloud/hcloud-go/hcloud" "github.com/hetznercloud/hcloud-go/hcloud"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
var rmServer bool var rmServer bool
var rmServerFlag = &cli.BoolFlag{ var rmServerFlag = &cli.BoolFlag{
Name: "server, s", Name: "server",
Aliases: []string{"s"},
Value: false,
Usage: "remove the actual server also", Usage: "remove the actual server also",
Destination: &rmServer, Destination: &rmServer,
} }
@ -49,7 +50,7 @@ func rmHetznerCloudVPS(c *cli.Context) error {
client := hcloud.NewClient(hcloud.WithToken(internal.HetznerCloudAPIToken)) client := hcloud.NewClient(hcloud.WithToken(internal.HetznerCloudAPIToken))
server, _, err := client.Server.Get(context.Background(), internal.HetznerCloudName) server, _, err := client.Server.Get(c.Context, internal.HetznerCloudName)
if err != nil { if err != nil {
return err return err
} }
@ -88,7 +89,7 @@ destroyed.
logrus.Fatal("exiting as requested") logrus.Fatal("exiting as requested")
} }
_, err = client.Server.Delete(context.Background(), server) _, err = client.Server.Delete(c.Context, server)
if err != nil { if err != nil {
return err return err
} }
@ -98,13 +99,13 @@ destroyed.
return nil return nil
} }
var serverRemoveCommand = cli.Command{ 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: ` Description: `
Remova a server from Abra management. This command removes a server from Abra management.
Depending on whether you used a 3rd party provider to create this server ("abra Depending on whether you used a 3rd party provider to create this server ("abra
server new"), you can also destroy the virtual server as well. Pass server new"), you can also destroy the virtual server as well. Pass
@ -115,8 +116,6 @@ underlying client connection context. This server will then be lost in time,
like tears in rain. like tears in rain.
`, `,
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
rmServerFlag, rmServerFlag,
internal.ServerProviderFlag, internal.ServerProviderFlag,
@ -124,26 +123,22 @@ like tears in rain.
internal.HetznerCloudNameFlag, internal.HetznerCloudNameFlag,
internal.HetznerCloudAPITokenFlag, internal.HetznerCloudAPITokenFlag,
}, },
Before: internal.SubCommandBefore,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
serverName := internal.ValidateServer(c) serverName := c.Args().Get(1)
if serverName != "" {
warnMsg := `Did not pass -s/--server for actual server deletion, prompting! var err error
serverName, err = internal.ValidateServer(c)
Abra doesn't currently know if it helped you create this server with one of the if err != nil {
3rd party integrations (e.g. Capsul). You have a choice here to actually, logrus.Fatal(err)
really and finally destroy this server using those integrations. If you want to }
do this, choose Yes. }
If you just want to remove the server config files & context, choose No.
`
if !rmServer { if !rmServer {
logrus.Warn(fmt.Sprintf(warnMsg)) logrus.Warn("did not pass -s/--server for actual server deletion, prompting")
response := false response := false
prompt := &survey.Confirm{ prompt := &survey.Confirm{
Message: "delete actual live server?", Message: "prompt to actual server deletion?",
} }
if err := survey.AskOne(prompt, &response); err != nil { if err := survey.AskOne(prompt, &response); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
@ -167,17 +162,20 @@ If you just want to remove the server config files & context, choose No.
logrus.Fatal(err) logrus.Fatal(err)
} }
} }
} }
if err := client.DeleteContext(serverName); err != nil { if serverName != "" {
logrus.Fatal(err) if err := client.DeleteContext(serverName); err != nil {
} logrus.Fatal(err)
}
if err := os.RemoveAll(filepath.Join(config.SERVERS_DIR, serverName)); err != nil { if err := os.RemoveAll(filepath.Join(config.SERVERS_DIR, 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,23 +1,24 @@
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",
Description: ` Description: `
Create, manage and remove servers using 3rd party integrations. 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 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 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 add". Abra can provision servers so that they are ready to deploy Co-op Cloud
recipes, see available flags on "abra server add" for more. apps, see available flags on "server add" for more.
`, `,
Subcommands: []cli.Command{ Subcommands: []*cli.Command{
serverNewCommand, serverNewCommand,
serverAddCommand, serverAddCommand,
serverListCommand, serverListCommand,

View File

@ -1,497 +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/runtime"
"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,
},
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,
},
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)
}
conf := runtime.New()
if !updateAll {
stackName := c.Args().Get(0)
recipeName := c.Args().Get(1)
err = tryUpgrade(cl, stackName, recipeName, conf)
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, conf)
if err != nil {
logrus.Fatal(err)
}
}
return nil
},
}
// getLabel reads docker labels 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.
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()
if err != nil {
return nil, err
}
versions, err := recipe.GetRecipeCatalogueVersions(recipeName, catl)
if err != nil {
return nil, err
}
if len(versions) == 0 {
return nil, fmt.Errorf("no published releases for %s in the recipe catalogue?", recipeName)
}
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, conf *runtime.Config) error {
if err := recipe.EnsureExists(recipeName, conf); 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, conf); 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, conf *runtime.Config) 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 disabling auto updates or missing ENABLE_AUTOUPDATE 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, conf)
return err
}
// upgrade performs all necessary steps to upgrade an app.
func upgrade(cl *dockerclient.Client, stackName, recipeName, upgradeVersion string, conf *runtime.Config) 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, conf); 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)
}
}

View File

@ -5,10 +5,10 @@ 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 git commit of Abra
var Commit string var Commit string
func main() { func main() {

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)
}

51
go.mod
View File

@ -4,47 +4,46 @@ 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.6 github.com/AlecAivazis/survey/v2 v2.3.2
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 v20.10.23+incompatible github.com/docker/cli v20.10.12+incompatible
github.com/docker/distribution v2.8.1+incompatible github.com/docker/distribution v2.7.1+incompatible
github.com/docker/docker v20.10.23+incompatible github.com/docker/docker v20.10.12+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.5.2 github.com/go-git/go-git/v5 v5.4.2
github.com/hetznercloud/hcloud-go v1.40.0 github.com/hetznercloud/hcloud-go v1.33.1
github.com/moby/sys/signal v0.7.0 github.com/moby/sys/signal v0.6.0
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 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.0 github.com/schollz/progressbar/v3 v3.8.5
github.com/sirupsen/logrus v1.9.0 github.com/schultz-is/passgen v1.0.1
gotest.tools/v3 v3.4.0 github.com/sirupsen/logrus v1.8.1
github.com/urfave/cli/v2 v2.3.0
gotest.tools/v3 v3.0.3
) )
require ( require (
coopcloud.tech/libcapsul v0.0.0-20211022074848-c35e78fe3f3e 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/buger/goterm v1.0.3
github.com/containers/image v3.0.2+incompatible github.com/containerd/containerd v1.5.5 // indirect
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.3.3
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.2 github.com/hashicorp/go-retryablehttp v0.7.0
github.com/klauspost/pgzip v1.2.5 github.com/kevinburke/ssh_config v1.1.0
github.com/libdns/gandi v1.0.2 github.com/libdns/gandi v1.0.2
github.com/libdns/libdns v0.2.1 github.com/libdns/libdns v0.2.1
github.com/moby/sys/mount v0.2.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/sergi/go-diff v1.2.0 // indirect github.com/opencontainers/runc v1.0.2 // indirect
github.com/spf13/cobra v1.3.0 // indirect
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-20211215153901-e495a2d5b3d3
golang.org/x/crypto v0.5.0 // indirect golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e
golang.org/x/sys v0.5.0
) )

677
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"
) )
@ -33,6 +37,45 @@ 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
// 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
}
// ParseServiceName parses a $STACK_NAME_$SERVICE_NAME service label. // ParseServiceName 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, "_")

View File

@ -6,7 +6,7 @@ import (
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/recipe"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli" "github.com/urfave/cli/v2"
) )
// AppNameComplete copletes app names // AppNameComplete copletes app names
@ -40,24 +40,3 @@ func RecipeNameComplete(c *cli.Context) {
fmt.Println(name) fmt.Println(name)
} }
} }
// SubcommandComplete completes subcommands.
func SubcommandComplete(c *cli.Context) {
if c.NArg() > 0 {
return
}
subcmds := []string{
"app",
"autocomplete",
"catalogue",
"recipe",
"record",
"server",
"upgrade",
}
for _, cmd := range subcmds {
fmt.Println(cmd)
}
}

View File

@ -1,122 +0,0 @@
package catalogue
import (
"fmt"
"os"
"path"
"strings"
"coopcloud.tech/abra/pkg/config"
gitPkg "coopcloud.tech/abra/pkg/git"
"github.com/go-git/go-git/v5"
"github.com/sirupsen/logrus"
)
// CatalogueSkipList is all the repos that are not recipes.
var CatalogueSkipList = map[string]bool{
"abra": true,
"abra-apps": true,
"abra-aur": true,
"abra-bash": true,
"abra-capsul": true,
"abra-gandi": true,
"abra-hetzner": true,
"apps": true,
"aur-abra-git": true,
"auto-recipes-catalogue-json": true,
"auto-mirror": 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.
func EnsureCatalogue() error {
catalogueDir := path.Join(config.ABRA_DIR, "catalogue")
if _, err := os.Stat(catalogueDir); err != nil && os.IsNotExist(err) {
url := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, config.CATALOGUE_JSON_REPO_NAME)
if err := gitPkg.Clone(catalogueDir, url); err != nil {
return err
}
logrus.Debugf("cloned catalogue repository to %s", catalogueDir)
}
return nil
}
// EnsureUpToDate ensures that the local catalogue has no unstaged changes as
// is up to date. This is useful to run before doing catalogue generation.
func EnsureUpToDate() error {
isClean, err := gitPkg.IsClean(config.CATALOGUE_DIR)
if err != nil {
return err
}
if !isClean {
msg := "%s has locally unstaged changes? please commit/remove your changes before proceeding"
return fmt.Errorf(msg, config.CATALOGUE_DIR)
}
repo, err := git.PlainOpen(config.CATALOGUE_DIR)
if err != nil {
return err
}
remotes, err := repo.Remotes()
if err != nil {
return err
}
if len(remotes) == 0 {
msg := "cannot ensure %s is up-to-date, no git remotes configured"
logrus.Debugf(msg, config.CATALOGUE_DIR)
return nil
}
worktree, err := repo.Worktree()
if err != nil {
return err
}
branch, err := gitPkg.CheckoutDefaultBranch(repo, config.CATALOGUE_DIR)
if err != nil {
return err
}
opts := &git.PullOptions{
Force: true,
ReferenceName: branch,
}
if err := worktree.Pull(opts); err != nil {
if !strings.Contains(err.Error(), "already up-to-date") {
return err
}
}
logrus.Debugf("fetched latest git changes for %s", config.CATALOGUE_DIR)
return 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

@ -1,28 +1,193 @@
package client package client
import ( import (
"context" "encoding/base64"
"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"
"github.com/docker/docker/client"
"github.com/hashicorp/go-retryablehttp"
"github.com/sirupsen/logrus"
) )
// 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)) type RawTags []RawTag
if err != nil {
return tags, fmt.Errorf("failed to parse image %s, saw: %s", img, err.Error())
}
ctx := context.Background() var registryURL = "https://registry.hub.docker.com/v1/repositories/%s/tags"
tags, err = docker.GetRepositoryTags(ctx, &types.SystemContext{}, ref)
if err != nil { 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
} }
func basicAuth(username, password string) string {
auth := username + ":" + password
return base64.StdEncoding.EncodeToString([]byte(auth))
}
// getRegv2Token retrieves a registry v2 authentication token.
func getRegv2Token(cl *client.Client, image reference.Named, registryUsername, registryPassword string) (string, error) {
img := reference.Path(image)
tokenURL := "https://auth.docker.io/token"
values := fmt.Sprintf("service=registry.docker.io&scope=repository:%s:pull", img)
fullURL := fmt.Sprintf("%s?%s", tokenURL, values)
req, err := retryablehttp.NewRequest("GET", fullURL, nil)
if err != nil {
return "", err
}
if registryUsername != "" && registryPassword != "" {
logrus.Debugf("using registry log in credentials for token request")
auth := basicAuth(registryUsername, registryPassword)
req.Header.Add("Authorization", fmt.Sprintf("Basic %s", auth))
}
client := web.NewHTTPRetryClient()
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 {
AccessToken string `json:"access_token"`
Expiry int `json:"expires_in"`
Issued string `json:"issued_at"`
Token string `json:"token"`
}{}
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(cl *client.Client, image reference.Named, registryUsername, registryPassword string) (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 := retryablehttp.NewRequest("GET", manifestURL, nil)
if err != nil {
return "", err
}
token, err := getRegv2Token(cl, image, registryUsername, registryPassword)
if err != nil {
return "", err
}
if token == "" {
return "", fmt.Errorf("unable to retrieve registry token?")
}
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 := web.NewHTTPRetryClient()
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

@ -5,14 +5,23 @@ import (
"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/docker/docker/client" "github.com/sirupsen/logrus"
) )
func GetVolumes(cl *client.Client, ctx context.Context, server string, fs filters.Args) ([]*types.Volume, error) { func GetVolumes(ctx context.Context, server string, appName string) ([]*types.Volume, error) {
cl, err := New(server)
if err != nil {
return nil, err
}
fs := filters.NewArgs()
fs.Add("name", appName)
volumeListOKBody, err := cl.VolumeList(ctx, fs) 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
@ -20,21 +29,23 @@ func GetVolumes(cl *client.Client, ctx context.Context, server string, fs filter
func GetVolumeNames(volumes []*types.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"
@ -54,11 +53,11 @@ func UpdateTag(pattern, image, tag, recipeName string) (bool, error) {
case reference.NamedTagged: case reference.NamedTagged:
composeTag = img.(reference.NamedTagged).Tag() composeTag = img.(reference.NamedTagged).Tag()
default: default:
logrus.Debugf("unable to parse %s, skipping", img) // unable to parse, typically image missing tag
continue return false, nil
} }
composeImage := formatter.StripTagMeta(reference.Path(img)) composeImage := reference.Path(img)
logrus.Debugf("parsed %s from %s", composeTag, service.Image) logrus.Debugf("parsed %s from %s", composeTag, service.Image)
@ -75,7 +74,7 @@ func UpdateTag(pattern, image, tag, recipeName string) (bool, error) {
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), 0764); err != nil {
return false, err return true, err
} }
} }
} }

View File

@ -2,21 +2,17 @@ package config
import ( import (
"fmt" "fmt"
"html/template"
"io/ioutil" "io/ioutil"
"os" "os"
"path" "path"
"strconv"
"strings" "strings"
"github.com/schollz/progressbar/v3"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/formatter" "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 +36,28 @@ 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. This should
// character, e.g. "_") stack name is for the app. In general, you don't want // not not shown to the user, use a.Name for that. Give the output of this
// to use this to show anything to end-users, you want use a.Name instead. // command to Docker only.
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 +68,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
@ -169,25 +118,22 @@ func readAppEnvFile(appFile AppFile, name AppName) (App, error) {
func newApp(env AppEnv, name string, appFile AppFile) (App, error) { func newApp(env AppEnv, name string, appFile AppFile) (App, error) {
domain := env["DOMAIN"] domain := env["DOMAIN"]
recipe, exists := env["RECIPE"] appType, exists := env["TYPE"]
if !exists { if !exists {
recipe, exists = env["TYPE"] return App{}, fmt.Errorf("%s is missing the TYPE env var", name)
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 {
@ -196,7 +142,7 @@ func LoadAppFiles(servers ...string) (AppFiles, error) {
var err error var err error
servers, err = GetAllFoldersInDirectory(SERVERS_DIR) servers, err = GetAllFoldersInDirectory(SERVERS_DIR)
if err != nil { if err != nil {
return appFiles, err return nil, err
} }
} }
} }
@ -205,11 +151,10 @@ func LoadAppFiles(servers ...string) (AppFiles, error) {
for _, server := range servers { for _, server := range servers {
serverDir := path.Join(SERVERS_DIR, server) serverDir := path.Join(SERVERS_DIR, 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(SERVERS_DIR, server, file.Name())
@ -219,13 +164,12 @@ func LoadAppFiles(servers ...string) (AppFiles, error) {
} }
} }
} }
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 {
@ -240,9 +184,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,14 +193,7 @@ func GetApps(appFiles AppFiles, recipeFilter string) ([]App, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
apps = append(apps, app)
if recipeFilter != "" {
if app.Recipe == recipeFilter {
apps = append(apps, app)
}
} else {
apps = append(apps, app)
}
} }
return apps, nil return apps, nil
@ -277,13 +213,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 +240,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,8 +252,7 @@ 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(recipeName, appName, server, domain string) error { func TemplateAppEnvSample(recipeName, appName, server, domain string) error {
envSamplePath := path.Join(RECIPES_DIR, recipeName, ".env.sample") envSamplePath := path.Join(RECIPES_DIR, recipeName, ".env.sample")
envSample, err := ioutil.ReadFile(envSamplePath) envSample, err := ioutil.ReadFile(envSamplePath)
@ -335,15 +270,18 @@ func TemplateAppEnvSample(recipeName, appName, server, domain string) error {
return err return err
} }
read, err := ioutil.ReadFile(appEnvPath) file, err := os.OpenFile(appEnvPath, os.O_RDWR, 0664)
if err != nil {
return err
}
defer file.Close()
tpl, err := template.ParseFiles(appEnvPath)
if err != nil { if err != nil {
return err return err
} }
newContents := strings.Replace(string(read), recipeName+".example.com", domain, -1) if err := tpl.Execute(file, struct{ Name string }{recipeName}); err != nil {
err = ioutil.WriteFile(appEnvPath, []byte(newContents), 0)
if err != nil {
return err return err
} }
@ -352,49 +290,35 @@ func TemplateAppEnvSample(recipeName, appName, server, domain string) error {
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]
@ -457,61 +381,3 @@ func GetAppComposeConfig(recipe string, opts stack.Deploy, appEnv AppEnv) (*comp
return compose, nil return compose, nil
} }
// ExposeAllEnv exposes all env variables to the app container
func ExposeAllEnv(stackName string, compose *composetypes.Config, appEnv AppEnv) {
for _, service := range compose.Services {
if service.Name == "app" {
logrus.Debugf("Add the following environment to the app service config of %s:", stackName)
for k, v := range appEnv {
_, 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
// 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)
}
}
}
// 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
}
}
}

View File

@ -16,13 +16,10 @@ import (
var ABRA_DIR = os.ExpandEnv("$HOME/.abra") var ABRA_DIR = os.ExpandEnv("$HOME/.abra")
var SERVERS_DIR = path.Join(ABRA_DIR, "servers") var SERVERS_DIR = path.Join(ABRA_DIR, "servers")
var RECIPES_DIR = path.Join(ABRA_DIR, "recipes") var RECIPES_DIR = path.Join(ABRA_DIR, "apps")
var VENDOR_DIR = path.Join(ABRA_DIR, "vendor") 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 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" var SSH_URL_TEMPLATE = "ssh://git@git.coopcloud.tech:2222/coop-cloud/%s.git"
// GetServers retrieves all servers. // GetServers retrieves all servers.
@ -66,8 +63,8 @@ func ReadServerNames() ([]string, error) {
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)

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,
@ -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

@ -13,10 +13,10 @@ import (
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
// GetContainer retrieves a container. If noInput is false and the retrievd // GetContainer retrieves a container. If prompt is true and the retrievd count
// count of containers does not match 1, then a prompt is presented to let the // of containers does not match 1, then a prompt is presented to let the user
// user choose. A count of 0 is handled gracefully. // choose. A count of 0 is handled gracefully.
func GetContainer(c context.Context, cl *client.Client, filters filters.Args, noInput bool) (types.Container, error) { func GetContainer(c context.Context, cl *client.Client, filters filters.Args, prompt bool) (types.Container, error) {
containerOpts := types.ContainerListOptions{Filters: filters} containerOpts := types.ContainerListOptions{Filters: filters}
containers, err := cl.ContainerList(c, containerOpts) containers, err := cl.ContainerList(c, containerOpts)
if err != nil { if err != nil {
@ -37,7 +37,7 @@ func GetContainer(c context.Context, cl *client.Client, filters filters.Args, no
containersRaw = append(containersRaw, fmt.Sprintf("%s (created %v)", trimmed, created)) containersRaw = append(containersRaw, fmt.Sprintf("%s (created %v)", trimmed, created))
} }
if noInput { if !prompt {
err := fmt.Errorf("expected 1 container but found %v: %s", len(containers), strings.Join(containersRaw, " ")) err := fmt.Errorf("expected 1 container but found %v: %s", len(containers), strings.Join(containersRaw, " "))
return types.Container{}, err return types.Container{}, err
} }

View File

@ -1,6 +1,7 @@
package dns package dns
import ( import (
"context"
"fmt" "fmt"
"net" "net"
"os" "os"
@ -31,20 +32,41 @@ func NewToken(provider, providerTokenEnvVar string) (string, error) {
// EnsureIPv4 ensures that an ipv4 address is set for a domain name // EnsureIPv4 ensures that an ipv4 address is set for a domain name
func EnsureIPv4(domainName string) (string, error) { func EnsureIPv4(domainName string) (string, error) {
ipv4, err := net.ResolveIPAddr("ip", domainName) var ipv4 string
if err != nil {
return "", err // 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)
},
} }
return ipv4.String(), nil logrus.Debugf("created DNS resolver via %s", freifunkDNS)
ctx := context.Background()
ips, err := resolver.LookupIPAddr(ctx, domainName)
if err != nil {
return ipv4, err
}
if len(ips) == 0 {
return ipv4, 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)
return ipv4, nil
} }
// EnsureDomainsResolveSameIPv4 ensures that domains resolve to the same ipv4 address // EnsureDomainsResolveSameIPv4 ensures that domains resolve to the same ipv4 address
func EnsureDomainsResolveSameIPv4(domainName, server string) (string, error) { func EnsureDomainsResolveSameIPv4(domainName, server string) (string, error) {
if server == "default" || server == "local" {
return "", nil
}
var ipv4 string var ipv4 string
domainIPv4, err := EnsureIPv4(domainName) domainIPv4, err := EnsureIPv4(domainName)

View File

@ -6,10 +6,8 @@ import (
"time" "time"
"github.com/docker/go-units" "github.com/docker/go-units"
// "github.com/olekukonko/tablewriter" "github.com/olekukonko/tablewriter"
"coopcloud.tech/abra/pkg/jsontable"
"github.com/schollz/progressbar/v3" "github.com/schollz/progressbar/v3"
"github.com/sirupsen/logrus"
) )
func ShortenID(str string) string { func ShortenID(str string) string {
@ -33,8 +31,8 @@ func HumanDuration(timestamp int64) string {
} }
// CreateTable prepares a table layout for output. // CreateTable prepares a table layout for output.
func CreateTable(columns []string) *jsontable.JSONTable { func CreateTable(columns []string) *tablewriter.Table {
table := jsontable.NewJSONTable(os.Stdout) table := tablewriter.NewWriter(os.Stdout)
table.SetAutoWrapText(false) table.SetAutoWrapText(false)
table.SetHeader(columns) table.SetHeader(columns)
return table return table
@ -51,22 +49,3 @@ func CreateProgressbar(length int, title string) *progressbar.ProgressBar {
progressbar.OptionSetDescription(title), 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
}

View File

@ -1,34 +1,11 @@
package git package git
import ( import (
"fmt"
"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"
) )
// Check if a branch exists in a repo. Use this and not repository.Branch(), // GetCurrentBranch retrieves the current branch of a repository
// 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) { func GetCurrentBranch(repository *git.Repository) (string, error) {
branchRefs, err := repository.Branches() branchRefs, err := repository.Branches()
if err != nil { if err != nil {
@ -56,45 +33,3 @@ func GetCurrentBranch(repository *git.Repository) (string, error) {
return currentBranchName, nil 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

@ -15,32 +15,22 @@ import (
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", dir)

View File

@ -1,203 +0,0 @@
package jsontable
import (
"fmt"
"io"
"github.com/olekukonko/tablewriter"
)
// A quick-and-dirty proxy/emulator of tablewriter to enable more easy machine readable output
// - Does not strictly support types, just quoted or unquoted values
// - Does not support nested values.
// If a datalabel is set with SetDataLabel(true, "..."), that will be used as the key for teh data of the table,
// otherwise if the caption is set with SetCaption(true, "..."), the data label will be set to the default of
// "rows", otherwise the table will output as a JSON list.
//
// Proxys all actions through to the tablewriter except addrow and addbatch, which it does at render time
//
type JSONTable struct {
out io.Writer
colsize int
rows [][]string
keys []string
quoted []bool // hack to do output typing, quoted vs. unquoted
hasDataLabel bool
dataLabel string
hasCaption bool
caption string // the actual caption
hasCaptionLabel bool
captionLabel string // the key in the dictionary for the caption
tbl *tablewriter.Table
}
func writeChar(w io.Writer, c byte) {
w.Write([]byte{c})
}
func NewJSONTable(writer io.Writer) *JSONTable {
t := &JSONTable{
out: writer,
colsize: 0,
rows: [][]string{},
keys: []string{},
quoted: []bool{},
hasDataLabel: false,
dataLabel: "rows",
hasCaption: false,
caption: "",
hasCaptionLabel: false,
captionLabel: "caption",
tbl: tablewriter.NewWriter(writer),
}
return t
}
func (t *JSONTable) NumLines() int {
// JSON only but reflects a shared state.
return len(t.rows)
}
func (t *JSONTable) SetHeader(keys []string) {
// Set the keys value which will assign each column to the keys.
// Note that we'll ignore values that are beyond the length of the keys list
t.colsize = len(keys)
t.keys = []string{}
for _, k := range keys {
t.keys = append(t.keys, k)
t.quoted = append(t.quoted, true)
}
t.tbl.SetHeader(keys)
}
func (t *JSONTable) SetColumnQuoting(quoting []bool) {
// Specify which columns are quoted or unquoted in output
// JSON only
for i := 0; i < t.colsize; i++ {
t.quoted[i] = quoting[i]
}
}
func (t *JSONTable) Append(row []string) {
// We'll just append whatever to the rows list. If they fix the keys after appending rows, it'll work as
// expected.
// We should detect if the row is narrower than the key list tho.
// JSON only (but we use the rows later when rendering a regular table)
t.rows = append(t.rows, row)
}
func (t *JSONTable) Render() {
// Load the table with rows and render.
// Proxy only
for _, row := range t.rows {
t.tbl.Append(row)
}
t.tbl.Render()
}
func (t *JSONTable) _JSONRenderInner() {
// JSON only
// Render the list of dictionaries to the writer.
//// inner render loop
writeChar(t.out, '[')
for rowidx, row := range t.rows {
if rowidx != 0 {
writeChar(t.out, ',')
}
writeChar(t.out, '{')
for keyidx, key := range t.keys {
value := "nil"
if keyidx < len(row) {
value = row[keyidx]
}
if keyidx != 0 {
writeChar(t.out, ',')
}
if t.quoted[keyidx] {
fmt.Fprintf(t.out, "\"%s\":\"%s\"", key, value)
} else {
fmt.Fprintf(t.out, "\"%s\":%s", key, value)
}
}
writeChar(t.out, '}')
}
writeChar(t.out, ']')
}
func (t *JSONTable) JSONRender() {
// write JSON table to output
// JSON only
if t.hasDataLabel || t.hasCaption {
// dict mode
writeChar(t.out, '{')
if t.hasCaption {
fmt.Fprintf(t.out, "\"%s\":\"%s\",", t.captionLabel, t.caption)
}
fmt.Fprintf(t.out, "\"%s\":", t.dataLabel)
}
// write list
t._JSONRenderInner()
if t.hasDataLabel || t.hasCaption {
// dict mode
writeChar(t.out, '}')
}
}
func (t *JSONTable) SetCaption(caption bool, captionText ...string) {
t.hasCaption = caption
if len(captionText) == 1 {
t.caption = captionText[0]
}
t.tbl.SetCaption(caption, captionText...)
}
func (t *JSONTable) SetCaptionLabel(captionLabel bool, captionLabelText ...string) {
// JSON only
t.hasCaptionLabel = captionLabel
if len(captionLabelText) == 1 {
t.captionLabel = captionLabelText[0]
}
}
func (t *JSONTable) SetDataLabel(dataLabel bool, dataLabelText ...string) {
// JSON only
t.hasDataLabel = dataLabel
if len(dataLabelText) == 1 {
t.dataLabel = dataLabelText[0]
}
}
func (t *JSONTable) AppendBulk(rows [][]string) {
// JSON only but reflects shared state
for _, row := range rows {
t.Append(row)
}
}
// Stuff we should implement but we just proxy for now.
func (t *JSONTable) SetAutoMergeCellsByColumnIndex(cols []int) {
// FIXME
t.tbl.SetAutoMergeCellsByColumnIndex(cols)
}
func (t *JSONTable) SetAutoMergeCells(auto bool) {
// FIXME
t.tbl.SetAutoMergeCells(auto)
}
// Stub functions
func (t *JSONTable) SetAutoWrapText(auto bool) {
t.tbl.SetAutoWrapText(auto)
return
}

View File

@ -1,83 +0,0 @@
package jsontable
import (
"testing"
"bytes"
"encoding/json"
"github.com/olekukonko/tablewriter"
)
var TestLine = []string{"1", "2"}
var TestGroup = [][]string{{"1", "2", "3"}, {"a", "teohunteohu", "c", "d"}, {"☺", "☹"}}
var TestKeys = []string{"key0", "key1", "key2"}
// test creation
func TestNewTable(t *testing.T) {
var b bytes.Buffer
tbl := NewJSONTable(&b)
if tbl.NumLines() != 0 {
t.Fatalf("Something went weird when making table (should have 0 lines)")
}
}
// test adding things
func TestTableAdd(t *testing.T) {
var b bytes.Buffer
tbl := NewJSONTable(&b)
tbl.Append(TestLine)
if tbl.NumLines() != 1 {
t.Fatalf("Appending a line does not result in a length of 1.")
}
tbl.AppendBulk(TestGroup)
numlines := tbl.NumLines()
if numlines != (len(TestGroup) + 1) {
t.Fatalf("Appending two lines does not result in a length of 4 (length is %d).", numlines)
}
}
// test JSON output is parsable
func TestJsonParsable(t *testing.T) {
var b bytes.Buffer
tbl := NewJSONTable(&b)
tbl.AppendBulk(TestGroup)
tbl.SetHeader(TestKeys)
tbl.JSONRender()
var son []map[string]interface{}
err := json.Unmarshal(b.Bytes(), &son)
if err != nil {
t.Fatalf("Did not produce parsable JSON: %s", err.Error())
}
}
// test identical commands to a tablewriter and jsontable produce the same rendered output
func TestTableWriter(t *testing.T) {
var bjson bytes.Buffer
var btable bytes.Buffer
tbl := NewJSONTable(&bjson)
tbl.AppendBulk(TestGroup)
tbl.SetHeader(TestKeys)
tbl.Render()
wtbl := tablewriter.NewWriter(&btable)
wtbl.AppendBulk(TestGroup)
wtbl.SetHeader(TestKeys)
wtbl.Render()
if bytes.Compare(bjson.Bytes(), btable.Bytes()) != 0 {
t.Fatalf("JSON table and TableWriter produce non-identical outputs.\n%s\n%s", bjson.Bytes(), btable.Bytes())
}
}
/// FIXME test different output formats when captions etc. are added

View File

@ -19,40 +19,12 @@ var Critical = "critical"
type LintFunction func(recipe.Recipe) (bool, error) type LintFunction func(recipe.Recipe) (bool, error)
// SkipFunction determines whether the LintFunction is run or not. It should
// not take the lint rule level into account because some rules are always an
// error but may depend on some additional context of the recipe configuration.
// This function aims to cover those additional cases.
type SkipFunction func(recipe.Recipe) (bool, error)
// LintRule is a linting rule which helps a recipe maintainer avoid common
// problems in their recipe configurations. We aim to highlight things that
// might result in critical errors or hours lost in debugging obscure
// Docker-isms. Humans make the final call on these rules, please raise an
// issue if you disagree.
type LintRule struct { type LintRule struct {
Ref string // Reference of the linting rule Ref string
Level string // Level of the warning Level string
Description string // Description of the issue Description string
HowToResolve string // Documentation for recipe maintainer HowToResolve string
Function LintFunction // Rule implementation Function LintFunction
SkipCondition SkipFunction // Whether or not to execute the lint rule
}
// Skip implements the SkipFunction for the lint rule.
func (l LintRule) Skip(recipe recipe.Recipe) bool {
if l.SkipCondition != nil {
ok, err := l.SkipCondition(recipe)
if err != nil {
logrus.Debugf("%s: skip condition: %s", l.Ref, err)
}
if ok {
logrus.Debugf("skipping %s based on skip condition", l.Ref)
return true
}
}
return false
} }
var LintRules = map[string][]LintRule{ var LintRules = map[string][]LintRule{
@ -106,13 +78,6 @@ var LintRules = map[string][]LintRule{
HowToResolve: "fill out all the metadata", HowToResolve: "fill out all the metadata",
Function: LintMetadataFilledIn, Function: LintMetadataFilledIn,
}, },
{
Ref: "R013",
Level: "warn",
Description: "git.coopcloud.tech repo exists",
HowToResolve: "upload your recipe to git.coopcloud.tech/coop-cloud/...",
Function: LintHasRecipeRepo,
},
}, },
"error": { "error": {
{ {
@ -130,12 +95,11 @@ var LintRules = map[string][]LintRule{
Function: LintAppService, Function: LintAppService,
}, },
{ {
Ref: "R010", Ref: "R010",
Level: "error", Level: "error",
Description: "traefik routing enabled", Description: "traefik routing enabled",
HowToResolve: "include \"traefik.enable=true\" deploy label", HowToResolve: "include \"traefik.enable=true\" deploy label",
Function: LintTraefikEnabled, Function: LintTraefikEnabled,
SkipCondition: LintTraefikEnabledSkipCondition,
}, },
{ {
Ref: "R011", Ref: "R011",
@ -151,12 +115,16 @@ var LintRules = map[string][]LintRule{
HowToResolve: "vendor config versions in an abra.sh", HowToResolve: "vendor config versions in an abra.sh",
Function: LintAbraShVendors, Function: LintAbraShVendors,
}, },
{
Ref: "R013",
Level: "error",
Description: "git.coopcloud.tech repo exists",
HowToResolve: "upload your recipe to git.coopcloud.tech/coop-cloud/...",
Function: LintHasRecipeRepo,
},
}, },
} }
// LintForErrors lints specifically for errors and not other levels. This is
// used in code paths such as "app deploy" to avoid nasty surprises but not for
// the typical linting commands, which do handle other levels.
func LintForErrors(recipe recipe.Recipe) error { func LintForErrors(recipe recipe.Recipe) error {
logrus.Debugf("linting for critical errors in %s configs", recipe.Name) logrus.Debugf("linting for critical errors in %s configs", recipe.Name)
@ -164,12 +132,7 @@ func LintForErrors(recipe recipe.Recipe) error {
if level != "error" { if level != "error" {
continue continue
} }
for _, rule := range LintRules[level] { for _, rule := range LintRules[level] {
if rule.Skip(recipe) {
continue
}
ok, err := rule.Function(recipe) ok, err := rule.Function(recipe)
if err != nil { if err != nil {
return err return err
@ -212,24 +175,6 @@ func LintAppService(recipe recipe.Recipe) (bool, error) {
return false, nil return false, nil
} }
// LintTraefikEnabledSkipCondition signals a skip for this linting rule if it
// confirms that there is no "DOMAIN=..." in the .env.sample configuration of
// the recipe. This typically means that no domain is required to deploy and
// therefore no matching traefik deploy label will be present.
func LintTraefikEnabledSkipCondition(recipe recipe.Recipe) (bool, error) {
envSamplePath := path.Join(config.RECIPES_DIR, recipe.Name, ".env.sample")
sampleEnv, err := config.ReadEnv(envSamplePath)
if err != nil {
return false, fmt.Errorf("Unable to discover .env.sample for %s", recipe.Name)
}
if _, ok := sampleEnv["DOMAIN"]; !ok {
return true, nil
}
return false, nil
}
func LintTraefikEnabled(recipe recipe.Recipe) (bool, error) { func LintTraefikEnabled(recipe recipe.Recipe) (bool, error) {
for _, service := range recipe.Config.Services { for _, service := range recipe.Config.Services {
for label := range service.Deploy.Labels { for label := range service.Deploy.Labels {

View File

@ -2,36 +2,31 @@ package recipe
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"time" "time"
"coopcloud.tech/abra/pkg/catalogue" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/compose" "coopcloud.tech/abra/pkg/compose"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/formatter"
gitPkg "coopcloud.tech/abra/pkg/git" gitPkg "coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/limit"
"coopcloud.tech/abra/pkg/runtime"
"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"
"coopcloud.tech/abra/pkg/web" "coopcloud.tech/abra/pkg/web"
composetypes "github.com/docker/cli/cli/compose/types" composetypes "github.com/docker/cli/cli/compose/types"
"github.com/docker/distribution/reference" "github.com/docker/distribution/reference"
"github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5"
gitConfig "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"
) )
// RecipeCatalogueURL is the only current recipe catalogue available. // RecipeCatalogueURL is the only current recipe catalogue available.
const RecipeCatalogueURL = "https://recipes.coopcloud.tech/recipes.json" const RecipeCatalogueURL = "https://apps.coopcloud.tech"
// ReposMetadataURL is the recipe repository metadata // ReposMetadataURL is the recipe repository metadata
const ReposMetadataURL = "https://git.coopcloud.tech/api/v1/orgs/coop-cloud/repos" const ReposMetadataURL = "https://git.coopcloud.tech/api/v1/orgs/coop-cloud/repos"
@ -44,8 +39,9 @@ type service = string
// ServiceMeta represents meta info associated with a service. // ServiceMeta represents meta info associated with a service.
type ServiceMeta struct { type ServiceMeta struct {
Image string `json:"image"` Digest string `json:"digest"`
Tag string `json:"tag"` Image string `json:"image"`
Tag string `json:"tag"`
} }
// RecipeVersions are the versions associated with a recipe. // RecipeVersions are the versions associated with a recipe.
@ -170,7 +166,7 @@ func (r Recipe) UpdateLabel(pattern, serviceName, label string) error {
func (r Recipe) UpdateTag(image, tag string) (bool, error) { func (r Recipe) UpdateTag(image, tag string) (bool, error) {
pattern := fmt.Sprintf("%s/%s/compose**yml", config.RECIPES_DIR, r.Name) pattern := fmt.Sprintf("%s/%s/compose**yml", config.RECIPES_DIR, r.Name)
image = formatter.StripTagMeta(image) image = StripTagMeta(image)
ok, err := compose.UpdateTag(pattern, image, tag, r.Name) ok, err := compose.UpdateTag(pattern, image, tag, r.Name)
if err != nil { if err != nil {
@ -207,8 +203,8 @@ func (r Recipe) Tags() ([]string, error) {
} }
// Get retrieves a recipe. // Get retrieves a recipe.
func Get(recipeName string, conf *runtime.Config) (Recipe, error) { func Get(recipeName string) (Recipe, error) {
if err := EnsureExists(recipeName, conf); err != nil { if err := EnsureExists(recipeName); err != nil {
return Recipe{}, err return Recipe{}, err
} }
@ -234,13 +230,9 @@ func Get(recipeName string, conf *runtime.Config) (Recipe, error) {
return Recipe{}, err return Recipe{}, err
} }
meta, err := GetRecipeMeta(recipeName, conf) meta, err := GetRecipeMeta(recipeName)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "does not exist") { return Recipe{}, err
meta = RecipeMeta{}
} else {
return Recipe{}, err
}
} }
return Recipe{ return Recipe{
@ -251,11 +243,7 @@ func Get(recipeName string, conf *runtime.Config) (Recipe, error) {
} }
// EnsureExists ensures that a recipe is locally cloned // EnsureExists ensures that a recipe is locally cloned
func EnsureExists(recipeName string, conf *runtime.Config) error { func EnsureExists(recipeName string) error {
if !conf.EnsureRecipeExists {
return nil
}
recipeDir := path.Join(config.RECIPES_DIR, recipeName) recipeDir := path.Join(config.RECIPES_DIR, recipeName)
if _, err := os.Stat(recipeDir); os.IsNotExist(err) { if _, err := os.Stat(recipeDir); os.IsNotExist(err) {
@ -315,7 +303,8 @@ func EnsureVersion(recipeName, version string) error {
logrus.Debugf("read %s as tags for recipe %s", strings.Join(parsedTags, ", "), recipeName) logrus.Debugf("read %s as tags for recipe %s", strings.Join(parsedTags, ", "), recipeName)
if tagRef.String() == "" { if tagRef.String() == "" {
return fmt.Errorf("no published release discovered for %s", recipeName) logrus.Warnf("no published release discovered for %s", recipeName)
return nil
} }
worktree, err := repo.Worktree() worktree, err := repo.Worktree()
@ -338,7 +327,7 @@ func EnsureVersion(recipeName, version string) error {
} }
// EnsureLatest makes sure the latest commit is checked out for a local recipe repository // EnsureLatest makes sure the latest commit is checked out for a local recipe repository
func EnsureLatest(recipeName string, conf *runtime.Config) error { func EnsureLatest(recipeName string) error {
recipeDir := path.Join(config.RECIPES_DIR, recipeName) recipeDir := path.Join(config.RECIPES_DIR, recipeName)
isClean, err := gitPkg.IsClean(recipeDir) isClean, err := gitPkg.IsClean(recipeDir)
@ -366,21 +355,11 @@ func EnsureLatest(recipeName string, conf *runtime.Config) error {
return err return err
} }
meta, err := GetRecipeMeta(recipeName, conf) branch, err := gitPkg.GetCurrentBranch(repo)
if err != nil { if err != nil {
return err return err
} }
var branch plumbing.ReferenceName
if meta.DefaultBranch != "" {
branch = plumbing.ReferenceName(fmt.Sprintf("refs/heads/%s", meta.DefaultBranch))
} else {
branch, err = gitPkg.GetDefaultBranch(repo, recipeDir)
if err != nil {
return err
}
}
checkOutOpts := &git.CheckoutOptions{ checkOutOpts := &git.CheckoutOptions{
Create: false, Create: false,
Force: true, Force: true,
@ -480,9 +459,6 @@ func GetRecipeFeaturesAndCategory(recipeName string) (Features, string, error) {
"\n") "\n")
for _, val := range readmeLines { for _, val := range readmeLines {
if strings.Contains(val, "**Status**") {
feat.Status, _ = strconv.Atoi(strings.TrimSpace(strings.Split(strings.TrimPrefix(val, "* **Status**:"), ",")[0]))
}
if strings.Contains(val, "**Category**") { if strings.Contains(val, "**Category**") {
category = strings.TrimSpace( category = strings.TrimSpace(
strings.TrimPrefix(val, "* **Category**:"), strings.TrimPrefix(val, "* **Category**:"),
@ -588,22 +564,21 @@ func EnsureUpToDate(recipeName string) error {
isClean, err := gitPkg.IsClean(recipeDir) isClean, err := gitPkg.IsClean(recipeDir)
if err != nil { if err != nil {
return fmt.Errorf("unable to check git clean status in %s: %s", recipeDir, err) return err
} }
if !isClean { if !isClean {
msg := "%s (%s) has locally unstaged changes? please commit/remove your changes before proceeding" return fmt.Errorf("%s has locally unstaged changes", recipeName)
return fmt.Errorf(msg, recipeName, recipeDir)
} }
repo, err := git.PlainOpen(recipeDir) repo, err := git.PlainOpen(recipeDir)
if err != nil { if err != nil {
return fmt.Errorf("unable to open %s: %s", recipeDir, err) return err
} }
remotes, err := repo.Remotes() remotes, err := repo.Remotes()
if err != nil { if err != nil {
return fmt.Errorf("unable to read remotes in %s: %s", recipeDir, err) return err
} }
if len(remotes) == 0 { if len(remotes) == 0 {
@ -613,35 +588,22 @@ func EnsureUpToDate(recipeName string) error {
worktree, err := repo.Worktree() worktree, err := repo.Worktree()
if err != nil { if err != nil {
return fmt.Errorf("unable to open git work tree in %s: %s", recipeDir, err) return err
} }
branch, err := gitPkg.CheckoutDefaultBranch(repo, recipeDir) branch, err := CheckoutDefaultBranch(repo, recipeName)
if err != nil { if err != nil {
return fmt.Errorf("unable to check out default branch in %s: %s", recipeDir, err) return err
}
fetchOpts := &git.FetchOptions{
Tags: git.AllTags,
RefSpecs: []gitConfig.RefSpec{
gitConfig.RefSpec(fmt.Sprintf("%s:%s", branch, branch)),
},
}
if err := repo.Fetch(fetchOpts); err != nil {
if !strings.Contains(err.Error(), "already up-to-date") {
return fmt.Errorf("unable to fetch tags in %s: %s", recipeDir, err)
}
} }
opts := &git.PullOptions{ opts := &git.PullOptions{
Force: true, Force: true,
ReferenceName: branch, ReferenceName: branch,
SingleBranch: true,
} }
if err := worktree.Pull(opts); err != nil { if err := worktree.Pull(opts); err != nil {
if !strings.Contains(err.Error(), "already up-to-date") { if !strings.Contains(err.Error(), "already up-to-date") {
return fmt.Errorf("unable to git pull in %s: %s", recipeDir, err) return err
} }
} }
@ -650,12 +612,49 @@ func EnsureUpToDate(recipeName string) error {
return nil return nil
} }
type CatalogueOfflineError struct { func GetDefaultBranch(repo *git.Repository, recipeName string) (plumbing.ReferenceName, error) {
msg string recipeDir := path.Join(config.RECIPES_DIR, recipeName)
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)
return "", err
}
branch = "main"
}
return plumbing.ReferenceName(fmt.Sprintf("refs/heads/%s", branch)), nil
} }
func (e *CatalogueOfflineError) Error() string { func CheckoutDefaultBranch(repo *git.Repository, recipeName string) (plumbing.ReferenceName, error) {
return fmt.Sprintf("catalogue offline: %s", e.msg) recipeDir := path.Join(config.RECIPES_DIR, recipeName)
branch, err := GetDefaultBranch(repo, recipeName)
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 {
recipeDir := path.Join(config.RECIPES_DIR, recipeName)
logrus.Debugf("failed to check out %s in %s", branch, recipeDir)
return branch, err
}
logrus.Debugf("successfully checked out %v in %s", branch, recipeDir)
return branch, nil
} }
// recipeCatalogueFSIsLatest checks whether the recipe catalogue stored locally // recipeCatalogueFSIsLatest checks whether the recipe catalogue stored locally
@ -664,7 +663,7 @@ func recipeCatalogueFSIsLatest() (bool, error) {
httpClient := web.NewHTTPRetryClient() httpClient := web.NewHTTPRetryClient()
res, err := httpClient.Head(RecipeCatalogueURL) res, err := httpClient.Head(RecipeCatalogueURL)
if err != nil { if err != nil {
return false, &CatalogueOfflineError{err.Error()} return false, err
} }
lastModified := res.Header["Last-Modified"][0] lastModified := res.Header["Last-Modified"][0]
@ -690,7 +689,7 @@ func recipeCatalogueFSIsLatest() (bool, error) {
return false, nil return false, nil
} }
logrus.Debug("file system cached recipe catalogue is up-to-date") logrus.Debug("file system cached recipe catalogue is now up-to-date")
return true, nil return true, nil
} }
@ -699,29 +698,20 @@ func recipeCatalogueFSIsLatest() (bool, error) {
func ReadRecipeCatalogue() (RecipeCatalogue, error) { func ReadRecipeCatalogue() (RecipeCatalogue, error) {
recipes := make(RecipeCatalogue) recipes := make(RecipeCatalogue)
if err := catalogue.EnsureCatalogue(); err != nil { recipeFSIsLatest, err := recipeCatalogueFSIsLatest()
if err != nil {
return nil, err return nil, err
} }
recipeFSIsLatest, err := recipeCatalogueFSIsLatest()
if err != nil {
var offlineErr *CatalogueOfflineError
if errors.As(err, &offlineErr) {
logrus.Error(err)
logrus.Error("unable to retrieve catalogue from internet, using local copy.")
recipeFSIsLatest = true
} else {
return nil, err
}
}
if !recipeFSIsLatest { if !recipeFSIsLatest {
logrus.Debugf("reading recipe catalogue from web to get latest")
if err := readRecipeCatalogueWeb(&recipes); err != nil { if err := readRecipeCatalogueWeb(&recipes); err != nil {
return nil, err return nil, err
} }
return recipes, nil return recipes, nil
} }
logrus.Debugf("reading recipe catalogue from file system cache to get latest")
if err := readRecipeCatalogueFS(&recipes); err != nil { if err := readRecipeCatalogueFS(&recipes); err != nil {
return nil, err return nil, err
} }
@ -795,7 +785,7 @@ func VersionsOfService(recipe, serviceName string) ([]string, error) {
} }
// GetRecipeMeta retrieves the recipe metadata from the recipe catalogue. // GetRecipeMeta retrieves the recipe metadata from the recipe catalogue.
func GetRecipeMeta(recipeName string, conf *runtime.Config) (RecipeMeta, error) { func GetRecipeMeta(recipeName string) (RecipeMeta, error) {
catl, err := ReadRecipeCatalogue() catl, err := ReadRecipeCatalogue()
if err != nil { if err != nil {
return RecipeMeta{}, err return RecipeMeta{}, err
@ -803,10 +793,11 @@ func GetRecipeMeta(recipeName string, conf *runtime.Config) (RecipeMeta, error)
recipeMeta, ok := catl[recipeName] recipeMeta, ok := catl[recipeName]
if !ok { if !ok {
return RecipeMeta{}, fmt.Errorf("recipe %s does not exist?", recipeName) err := fmt.Errorf("recipe %s does not exist?", recipeName)
return RecipeMeta{}, err
} }
if err := EnsureExists(recipeName, conf); err != nil { if err := EnsureExists(recipeName); err != nil {
return RecipeMeta{}, err return RecipeMeta{}, err
} }
@ -928,9 +919,8 @@ func ReadReposMetadata() (RepoCatalogue, error) {
} }
// GetRecipeVersions retrieves all recipe versions. // GetRecipeVersions retrieves all recipe versions.
func GetRecipeVersions(recipeName string, opts ...runtime.Option) (RecipeVersions, error) { func GetRecipeVersions(recipeName, registryUsername, registryPassword string) (RecipeVersions, error) {
versions := RecipeVersions{} versions := RecipeVersions{}
conf := runtime.New(opts...)
recipeDir := path.Join(config.RECIPES_DIR, recipeName) recipeDir := path.Join(config.RECIPES_DIR, recipeName)
@ -943,7 +933,7 @@ func GetRecipeVersions(recipeName string, opts ...runtime.Option) (RecipeVersion
worktree, err := repo.Worktree() worktree, err := repo.Worktree()
if err != nil { if err != nil {
return versions, err logrus.Fatal(err)
} }
gitTags, err := repo.Tags() gitTags, err := repo.Tags()
@ -968,11 +958,17 @@ func GetRecipeVersions(recipeName string, opts ...runtime.Option) (RecipeVersion
logrus.Debugf("successfully checked out %s in %s", ref.Name(), recipeDir) logrus.Debugf("successfully checked out %s in %s", ref.Name(), recipeDir)
recipe, err := Get(recipeName, conf) recipe, err := Get(recipeName)
if err != nil { if err != nil {
return err return err
} }
cl, err := client.New("default") // only required for docker.io registry calls
if err != nil {
logrus.Fatal(err)
}
queryCache := make(map[reference.Named]string)
versionMeta := make(map[string]ServiceMeta) versionMeta := make(map[string]ServiceMeta)
for _, service := range recipe.Config.Services { for _, service := range recipe.Config.Services {
@ -983,7 +979,7 @@ func GetRecipeVersions(recipeName string, opts ...runtime.Option) (RecipeVersion
path := reference.Path(img) path := reference.Path(img)
path = formatter.StripTagMeta(path) path = StripTagMeta(path)
var tag string var tag string
switch img.(type) { switch img.(type) {
@ -994,9 +990,27 @@ func GetRecipeVersions(recipeName string, opts ...runtime.Option) (RecipeVersion
continue continue
} }
var exists bool
var digest string
if digest, exists = queryCache[img]; !exists {
logrus.Debugf("looking up image: %s from %s", img, path)
var err error
digest, err = client.GetTagDigest(cl, img, registryUsername, registryPassword)
if err != nil {
logrus.Warn(err)
continue
}
logrus.Debugf("queried for image: %s, tag: %s, digest: %s", path, tag, digest)
queryCache[img] = digest
logrus.Debugf("cached image: %s, tag: %s, digest: %s", path, tag, digest)
} else {
logrus.Debugf("reading image: %s, tag: %s, digest: %s from cache", path, tag, digest)
}
versionMeta[service.Name] = ServiceMeta{ versionMeta[service.Name] = ServiceMeta{
Image: path, Digest: digest,
Tag: tag, Image: path,
Tag: img.(reference.NamedTagged).Tag(),
} }
} }
@ -1007,7 +1021,7 @@ func GetRecipeVersions(recipeName string, opts ...runtime.Option) (RecipeVersion
return versions, err return versions, err
} }
_, err = gitPkg.CheckoutDefaultBranch(repo, recipeDir) _, err = CheckoutDefaultBranch(repo, recipeName)
if err != nil { if err != nil {
return versions, err return versions, err
} }
@ -1032,53 +1046,21 @@ func GetRecipeCatalogueVersions(recipeName string, catl RecipeCatalogue) ([]stri
return versions, nil return versions, nil
} }
// UpdateRepositories clones and updates all recipe repositories locally. // StripTagMeta strips front-matter image tag data that we don't need for parsing.
func UpdateRepositories(repos RepoCatalogue, recipeName string) error { func StripTagMeta(image string) string {
var barLength int originalImage := image
if recipeName != "" {
barLength = 1 if strings.Contains(image, "docker.io") {
} else { image = strings.Split(image, "/")[1]
barLength = len(repos)
} }
cloneLimiter := limit.New(10) if strings.Contains(image, "library") {
image = strings.Split(image, "/")[1]
retrieveBar := formatter.CreateProgressbar(barLength, "ensuring recipes are cloned & up-to-date...")
ch := make(chan string, barLength)
for _, repoMeta := range repos {
go func(rm RepoMeta) {
cloneLimiter.Begin()
defer cloneLimiter.End()
if recipeName != "" && recipeName != rm.Name {
ch <- rm.Name
retrieveBar.Add(1)
return
}
if _, exists := catalogue.CatalogueSkipList[rm.Name]; exists {
ch <- rm.Name
retrieveBar.Add(1)
return
}
recipeDir := path.Join(config.RECIPES_DIR, rm.Name)
if err := gitPkg.Clone(recipeDir, rm.CloneURL); err != nil {
logrus.Fatal(err)
}
if err := EnsureUpToDate(rm.Name); err != nil {
logrus.Fatal(err)
}
ch <- rm.Name
retrieveBar.Add(1)
}(repoMeta)
} }
for range repos { if originalImage != image {
<-ch // wait for everything logrus.Debugf("stripped %s to %s for parsing", originalImage, image)
} }
return nil return image
} }

View File

@ -1,58 +0,0 @@
package runtime
import "github.com/sirupsen/logrus"
// Config is an internal configuration modifier. It can be instantiated on a
// command call and can be changed on the fly to help make decisions further
// down in the internals, e.g. whether or not to clone the recipe locally or
// not.
type Config struct {
EnsureRecipeExists bool // ensure that the recipe is cloned locally
EnsureRecipeLatest bool // ensure the local recipe has latest changes
}
// Option modified a Config.
type Option func(c *Config)
// New instantiates a new Config.
func New(opts ...Option) *Config {
conf := &Config{
EnsureRecipeExists: true,
}
for _, optFunc := range opts {
optFunc(conf)
}
return conf
}
// WithEnsureRecipeExists determines whether or not we should be cloning the
// local recipe or not. This can be useful for being more adaptable to offline
// scenarios.
func WithEnsureRecipeExists(ensureRecipeExists bool) Option {
return func(c *Config) {
if ensureRecipeExists {
logrus.Debugf("runtime config: EnsureRecipeExists = %v, ensuring recipes are cloned", ensureRecipeExists)
} else {
logrus.Debugf("runtime config: EnsureRecipeExists = %v, not cloning recipes", ensureRecipeExists)
}
c.EnsureRecipeExists = ensureRecipeExists
}
}
// WithEnsureRecipeLatest determines whether we should update the local recipes
// remotely via Git. This can be useful when e.g. ensuring we have the latest
// changes before making new ones.
func WithEnsureRecipeLatest(ensureRecipeLatest bool) Option {
return func(c *Config) {
if ensureRecipeLatest {
logrus.Debugf("runtime config: EnsureRecipeLatest = %v, ensuring recipes have latest changes", ensureRecipeLatest)
} else {
logrus.Debugf("runtime config: EnsureRecipeLatest = %v, leaving recipes alone", ensureRecipeLatest)
}
c.EnsureRecipeLatest = ensureRecipeLatest
}
}

View File

@ -8,12 +8,10 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"sync"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"github.com/decentral1se/passgen" "github.com/schultz-is/passgen"
dockerClient "github.com/docker/docker/client"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
@ -118,36 +116,27 @@ func ParseSecretEnvVarValue(secret string) (secretValue, error) {
} }
// GenerateSecrets generates secrets locally and sends them to a remote server for storage. // GenerateSecrets generates secrets locally and sends them to a remote server for storage.
func GenerateSecrets(cl *dockerClient.Client, secretEnvVars map[string]string, appName, server string) (map[string]string, error) { func GenerateSecrets(secretEnvVars map[string]string, appName, server string) (map[string]string, error) {
secrets := make(map[string]string) secrets := make(map[string]string)
var mutex sync.Mutex
var wg sync.WaitGroup
ch := make(chan error, len(secretEnvVars)) ch := make(chan error, len(secretEnvVars))
for secretEnvVar := range secretEnvVars { for secretEnvVar := range secretEnvVars {
wg.Add(1)
go func(s string) { go func(s string) {
defer wg.Done()
secretName := ParseSecretEnvVarName(s) secretName := ParseSecretEnvVarName(s)
secretValue, err := ParseSecretEnvVarValue(secretEnvVars[s]) secretValue, err := ParseSecretEnvVarValue(secretEnvVars[s])
if err != nil { if err != nil {
ch <- err ch <- err
return return
} }
secretRemoteName := fmt.Sprintf("%s_%s_%s", appName, secretName, secretValue.Version) secretRemoteName := fmt.Sprintf("%s_%s_%s", appName, secretName, secretValue.Version)
logrus.Debugf("attempting to generate and store %s on %s", secretRemoteName, server) logrus.Debugf("attempting to generate and store %s on %s", secretRemoteName, server)
if secretValue.Length > 0 { if secretValue.Length > 0 {
passwords, err := GeneratePasswords(1, uint(secretValue.Length)) passwords, err := GeneratePasswords(1, uint(secretValue.Length))
if err != nil { if err != nil {
ch <- err ch <- err
return return
} }
if err := client.StoreSecret(secretRemoteName, passwords[0], server); err != nil {
if err := client.StoreSecret(cl, secretRemoteName, passwords[0], server); err != nil {
if strings.Contains(err.Error(), "AlreadyExists") { if strings.Contains(err.Error(), "AlreadyExists") {
logrus.Warnf("%s already exists, moving on...", secretRemoteName) logrus.Warnf("%s already exists, moving on...", secretRemoteName)
ch <- nil ch <- nil
@ -156,9 +145,6 @@ func GenerateSecrets(cl *dockerClient.Client, secretEnvVars map[string]string, a
} }
return return
} }
mutex.Lock()
defer mutex.Unlock()
secrets[secretName] = passwords[0] secrets[secretName] = passwords[0]
} else { } else {
passphrases, err := GeneratePassphrases(1) passphrases, err := GeneratePassphrases(1)
@ -166,8 +152,7 @@ func GenerateSecrets(cl *dockerClient.Client, secretEnvVars map[string]string, a
ch <- err ch <- err
return return
} }
if err := client.StoreSecret(secretRemoteName, passphrases[0], server); err != nil {
if err := client.StoreSecret(cl, secretRemoteName, passphrases[0], server); err != nil {
if strings.Contains(err.Error(), "AlreadyExists") { if strings.Contains(err.Error(), "AlreadyExists") {
logrus.Warnf("%s already exists, moving on...", secretRemoteName) logrus.Warnf("%s already exists, moving on...", secretRemoteName)
ch <- nil ch <- nil
@ -176,17 +161,12 @@ func GenerateSecrets(cl *dockerClient.Client, secretEnvVars map[string]string, a
} }
return return
} }
mutex.Lock()
defer mutex.Unlock()
secrets[secretName] = passphrases[0] secrets[secretName] = passphrases[0]
} }
ch <- nil ch <- nil
}(secretEnvVar) }(secretEnvVar)
} }
wg.Wait()
for range secretEnvVars { for range secretEnvVars {
err := <-ch err := <-ch
if err != nil { if err != nil {

View File

@ -1,13 +1,37 @@
package ssh package ssh
import ( import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/base64"
"fmt" "fmt"
"os/exec" "io"
"net"
"os"
"os/user"
"path/filepath"
"strings" "strings"
"sync"
"time"
"coopcloud.tech/abra/pkg/context"
"github.com/AlecAivazis/survey/v2"
dockerSSHPkg "github.com/docker/cli/cli/connhelper/ssh"
sshPkg "github.com/gliderlabs/ssh"
"github.com/kevinburke/ssh_config"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"golang.org/x/crypto/ssh/knownhosts"
) )
var KnownHostsPath = filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts")
type Client struct {
SSHClient *ssh.Client
}
// HostConfig is a SSH host config. // HostConfig is a SSH host config.
type HostConfig struct { type HostConfig struct {
Host string Host string
@ -16,70 +40,504 @@ type HostConfig struct {
User string User string
} }
// String presents a human friendly output for the HostConfig. // Exec cmd on the remote host and return stderr and stdout
func (h HostConfig) String() string { func (c *Client) Exec(cmd string) ([]byte, error) {
return fmt.Sprintf( session, err := c.SSHClient.NewSession()
"{host: %s, username: %s, port: %s, identityfile: %s}", if err != nil {
h.Host, return nil, err
h.User, }
h.Port, defer session.Close()
h.IdentityFile,
) return session.CombinedOutput(cmd)
} }
// GetHostConfig retrieves a ~/.ssh/config config for a host using /usr/bin/ssh // Close the underlying SSH connection
// directly. We therefore maintain consistent interop with this standard func (c *Client) Close() error {
// tooling. This is useful because SSH confuses a lot of people and having to return c.SSHClient.Close()
// learn how two tools (`ssh` and `abra`) handle SSH connection details instead }
// of one (just `ssh`) is Not Cool. Here's to less bug reports on this topic!
func GetHostConfig(hostname string) (HostConfig, error) {
var hostConfig HostConfig
out, err := exec.Command("ssh", "-G", hostname).Output() // New creates a new SSH client connection.
func New(domainName, sshAuth, username, port string) (*Client, error) {
var client *Client
ctxConnDetails, err := GetContextConnDetails(domainName)
if err != nil { if err != nil {
return hostConfig, err return client, nil
} }
for _, line := range strings.Split(string(out), "\n") { if sshAuth == "identity-file" {
entries := strings.Split(line, " ") var err error
for idx, entry := range entries { client, err = connectWithAgentTimeout(
if entry == "hostname" { ctxConnDetails.Host,
hostConfig.Host = entries[idx+1] ctxConnDetails.User,
} ctxConnDetails.Port,
if entry == "user" { 5*time.Second,
hostConfig.User = entries[idx+1] )
} if err != nil {
if entry == "port" { return client, err
hostConfig.Port = entries[idx+1] }
} } else {
if entry == "identityfile" { password := ""
if hostConfig.IdentityFile == "" { prompt := &survey.Password{
hostConfig.IdentityFile = entries[idx+1] Message: "SSH password?",
} }
} if err := survey.AskOne(prompt, &password); err != nil {
return client, err
}
var err error
client, err = connectWithPasswordTimeout(
ctxConnDetails.Host,
ctxConnDetails.User,
ctxConnDetails.Port,
password,
5*time.Second,
)
if err != nil {
return client, err
} }
} }
logrus.Debugf("retrieved ssh config for %s: %s", hostname, hostConfig.String()) return client, nil
}
// sudoWriter supports sudo command handling
type sudoWriter struct {
b bytes.Buffer
pw string
stdin io.Writer
m sync.Mutex
}
// Write satisfies the write interface for sudoWriter
func (w *sudoWriter) Write(p []byte) (int, error) {
if strings.Contains(string(p), "sudo_password") {
w.stdin.Write([]byte(w.pw + "\n"))
w.pw = ""
return len(p), nil
}
w.m.Lock()
defer w.m.Unlock()
return w.b.Write(p)
}
// RunSudoCmd runs SSH commands and streams output
func RunSudoCmd(cmd, passwd string, cl *Client) error {
session, err := cl.SSHClient.NewSession()
if err != nil {
return err
}
defer session.Close()
sudoCmd := fmt.Sprintf("SSH_ASKPASS=/usr/bin/ssh-askpass; sudo -p sudo_password -S %s", cmd)
w := &sudoWriter{pw: passwd}
w.stdin, err = session.StdinPipe()
if err != nil {
return err
}
session.Stdout = w
session.Stderr = w
modes := ssh.TerminalModes{
ssh.ECHO: 0,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
}
err = session.RequestPty("xterm", 80, 40, modes)
if err != nil {
return err
}
if err := session.Run(sudoCmd); err != nil {
return fmt.Errorf("%s", string(w.b.Bytes()))
}
return nil
}
// EnsureKnowHostsFiles ensures that ~/.ssh/known_hosts is created
func EnsureKnowHostsFiles() error {
if _, err := os.Stat(KnownHostsPath); os.IsNotExist(err) {
logrus.Debugf("missing %s, creating now", KnownHostsPath)
file, err := os.OpenFile(KnownHostsPath, os.O_CREATE, 0600)
if err != nil {
return err
}
file.Close()
}
return nil
}
// GetHostKey checks if a host key is registered in the ~/.ssh/known_hosts file
func GetHostKey(hostname string) (bool, sshPkg.PublicKey, error) {
var hostKey sshPkg.PublicKey
ctxConnDetails, err := GetContextConnDetails(hostname)
if err != nil {
return false, hostKey, err
}
if err := EnsureKnowHostsFiles(); err != nil {
return false, hostKey, err
}
file, err := os.Open(KnownHostsPath)
if err != nil {
return false, hostKey, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
fields := strings.Split(scanner.Text(), " ")
if len(fields) != 3 {
continue
}
hostnameAndPort := fmt.Sprintf("%s:%s", ctxConnDetails.Host, ctxConnDetails.Port)
hashed := knownhosts.Normalize(hostnameAndPort)
if strings.Contains(fields[0], hashed) {
var err error
hostKey, _, _, _, err = ssh.ParseAuthorizedKey(scanner.Bytes())
if err != nil {
return false, hostKey, fmt.Errorf("error parsing server SSH host key %q: %v", fields[2], err)
}
break
}
}
if hostKey != nil {
logrus.Debugf("server SSH host key present in ~/.ssh/known_hosts for %s", hostname)
return true, hostKey, nil
}
return false, hostKey, nil
}
// InsertHostKey adds a new host key to the ~/.ssh/known_hosts file
func InsertHostKey(hostname string, remote net.Addr, pubKey ssh.PublicKey) error {
file, err := os.OpenFile(KnownHostsPath, os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
return err
}
defer file.Close()
hashedHostname := knownhosts.Normalize(hostname)
lineHostname := knownhosts.Line([]string{hashedHostname}, pubKey)
_, err = file.WriteString(fmt.Sprintf("%s\n", lineHostname))
if err != nil {
return err
}
hashedRemote := knownhosts.Normalize(remote.String())
lineRemote := knownhosts.Line([]string{hashedRemote}, pubKey)
_, err = file.WriteString(fmt.Sprintf("%s\n", lineRemote))
if err != nil {
return err
}
logrus.Debugf("SSH host key generated: %s", lineHostname)
logrus.Debugf("SSH host key generated: %s", lineRemote)
return nil
}
// HostKeyAddCallback ensures server ssh host keys are handled
func HostKeyAddCallback(hostnameAndPort string, remote net.Addr, pubKey ssh.PublicKey) error {
exists, _, err := GetHostKey(hostnameAndPort)
if err != nil {
return err
}
if exists {
hostname := strings.Split(hostnameAndPort, ":")[0]
logrus.Debugf("server SSH host key found for %s", hostname)
return nil
}
if !exists {
hostname := strings.Split(hostnameAndPort, ":")[0]
parsedPubKey := FingerprintSHA256(pubKey)
fmt.Printf(fmt.Sprintf(`
You are attempting to make an SSH connection to a server but there is no entry
in your ~/.ssh/known_hosts file which confirms that you have already validated
that this is indeed the server you want to connect to. Please take a moment to
validate the following SSH host key, it is important.
Host: %s
Fingerprint: %s
If this is confusing to you, you can read the article below and learn how to
validate this fingerprint safely. Thanks to the comrades at cyberia.club for
writing this extensive guide <3
https://sequentialread.com/understanding-the-secure-shell-protocol-ssh/
`, hostname, parsedPubKey))
response := false
prompt := &survey.Confirm{
Message: "are you sure you trust this host key?",
}
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
if !response {
logrus.Fatal("exiting as requested")
}
logrus.Debugf("attempting to insert server SSH host key for %s, %s", hostnameAndPort, remote)
if err := InsertHostKey(hostnameAndPort, remote, pubKey); err != nil {
return err
}
logrus.Infof("successfully added server SSH host key for %s", hostname)
}
return nil
}
// connect makes the SSH connection
func connect(username, host, port string, authMethod ssh.AuthMethod, timeout time.Duration) (*Client, error) {
config := &ssh.ClientConfig{
User: username,
Auth: []ssh.AuthMethod{authMethod},
HostKeyCallback: HostKeyAddCallback, // the main reason why we fork
}
hostnameAndPort := fmt.Sprintf("%s:%s", host, port)
logrus.Debugf("tcp dialing %s", hostnameAndPort)
var conn net.Conn
var err error
conn, err = net.DialTimeout("tcp", hostnameAndPort, timeout)
if err != nil {
logrus.Debugf("tcp dialing %s failed, trying via ~/.ssh/config", hostnameAndPort)
hostConfig, err := GetHostConfig(host, username, port)
if err != nil {
return nil, err
}
conn, err = net.DialTimeout("tcp", fmt.Sprintf("%s:%s", hostConfig.Host, hostConfig.Port), timeout)
if err != nil {
return nil, err
}
}
sshConn, chans, reqs, err := ssh.NewClientConn(conn, hostnameAndPort, config)
if err != nil {
return nil, err
}
client := ssh.NewClient(sshConn, chans, reqs)
c := &Client{SSHClient: client}
return c, nil
}
func connectWithAgentTimeout(host, username, port string, timeout time.Duration) (*Client, error) {
logrus.Debugf("using ssh-agent to make an SSH connection for %s", host)
sshAgent, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK"))
if err != nil {
return nil, err
}
agentCl := agent.NewClient(sshAgent)
authMethod := ssh.PublicKeysCallback(agentCl.Signers)
loadedKeys, err := agentCl.List()
if err != nil {
return nil, err
}
var convertedKeys []string
for _, key := range loadedKeys {
convertedKeys = append(convertedKeys, key.String())
}
if len(convertedKeys) > 0 {
logrus.Debugf("ssh-agent has these keys loaded: %s", strings.Join(convertedKeys, ","))
} else {
logrus.Debug("ssh-agent has no keys loaded")
}
return connect(username, host, port, authMethod, timeout)
}
func connectWithPasswordTimeout(host, username, port, pass string, timeout time.Duration) (*Client, error) {
authMethod := ssh.Password(pass)
return connect(username, host, port, authMethod, timeout)
}
// EnsureHostKey ensures that a host key trusted and added to the ~/.ssh/known_hosts file
func EnsureHostKey(hostname string) error {
if hostname == "default" || hostname == "local" {
logrus.Debugf("not checking server SSH host key against local/default target")
return nil
}
exists, _, err := GetHostKey(hostname)
if err != nil {
return err
}
if exists {
return nil
}
ctxConnDetails, err := GetContextConnDetails(hostname)
if err != nil {
return err
}
_, err = connectWithAgentTimeout(
ctxConnDetails.Host,
ctxConnDetails.User,
ctxConnDetails.Port,
5*time.Second,
)
if err != nil {
return err
}
return nil
}
// FingerprintSHA256 generates the SHA256 fingerprint for a server SSH host key
func FingerprintSHA256(key ssh.PublicKey) string {
hash := sha256.Sum256(key.Marshal())
b64hash := base64.StdEncoding.EncodeToString(hash[:])
trimmed := strings.TrimRight(b64hash, "=")
return fmt.Sprintf("SHA256:%s", trimmed)
}
// GetContextConnDetails retrieves SSH connection details from a docker context endpoint
func GetContextConnDetails(serverName string) (*dockerSSHPkg.Spec, error) {
dockerContextStore := context.NewDefaultDockerContextStore()
contexts, err := dockerContextStore.Store.List()
if err != nil {
return &dockerSSHPkg.Spec{}, err
}
if strings.Contains(serverName, ":") {
serverName = strings.Split(serverName, ":")[0]
}
for _, ctx := range contexts {
endpoint, err := context.GetContextEndpoint(ctx)
if err != nil && strings.Contains(err.Error(), "does not exist") {
// No local context found, we can continue safely
continue
}
if ctx.Name == serverName {
ctxConnDetails, err := dockerSSHPkg.ParseURL(endpoint)
if err != nil {
return &dockerSSHPkg.Spec{}, err
}
logrus.Debugf("found context connection details %v for %s", ctxConnDetails, serverName)
return ctxConnDetails, nil
}
}
hostConfig, err := GetHostConfig(serverName, "", "")
if err != nil {
return &dockerSSHPkg.Spec{}, err
}
logrus.Debugf("couldn't find a docker context matching %s", serverName)
logrus.Debugf("searching ~/.ssh/config for a Host entry for %s", serverName)
connDetails := &dockerSSHPkg.Spec{
Host: hostConfig.Host,
User: hostConfig.User,
Port: hostConfig.Port,
}
logrus.Debugf("using %v from ~/.ssh/config for connection details", connDetails)
return connDetails, nil
}
// GetHostConfig retrieves a ~/.ssh/config config for a host.
func GetHostConfig(hostname, username, port string) (HostConfig, error) {
var hostConfig HostConfig
if hostname == "" {
if hostname = ssh_config.Get(hostname, "Hostname"); hostname == "" {
logrus.Debugf("no hostname found in SSH config, assuming %s", hostname)
}
}
if username == "" {
if username = ssh_config.Get(hostname, "User"); username == "" {
systemUser, err := user.Current()
if err != nil {
return hostConfig, err
}
logrus.Debugf("no username found in SSH config or passed on command-line, assuming %s", username)
username = systemUser.Username
}
}
if port == "" {
if port = ssh_config.Get(hostname, "Port"); port == "" {
logrus.Debugf("no port found in SSH config or passed on command-line, assuming 22")
port = "22"
}
}
if idf := ssh_config.Get(hostname, "IdentityFile"); idf != "" && idf != "~/.ssh/identity" {
var err error
idf, err = identityFileAbsPath(idf)
if err != nil {
return hostConfig, err
}
hostConfig.IdentityFile = idf
} else {
logrus.Debugf("no identity file found in SSH config for %s", hostname)
hostConfig.IdentityFile = ""
}
hostConfig.Host = hostname
hostConfig.Port = port
hostConfig.User = username
logrus.Debugf("constructed SSH config %s for %s", hostConfig, hostname)
return hostConfig, nil return hostConfig, nil
} }
// Fatal is a error output wrapper which aims to make SSH failures easier to func identityFileAbsPath(relPath string) (string, error) {
// parse through re-wording. var err error
func Fatal(hostname string, err error) error { var absPath string
out := err.Error()
if strings.Contains(out, "Host key verification failed.") { if strings.HasPrefix(relPath, "~/") {
return fmt.Errorf("SSH host key verification failed for %s", hostname) systemUser, err := user.Current()
} else if strings.Contains(out, "Could not resolve hostname") { if err != nil {
return fmt.Errorf("could not resolve hostname for %s", hostname) return absPath, err
} else if strings.Contains(out, "Connection timed out") { }
return fmt.Errorf("connection timed out for %s", hostname) absPath = filepath.Join(systemUser.HomeDir, relPath[2:])
} else if strings.Contains(out, "Permission denied") {
return fmt.Errorf("ssh auth: permission denied for %s", hostname)
} else if strings.Contains(out, "Network is unreachable") {
return fmt.Errorf("unable to connect to %s, network is unreachable?", hostname)
} else { } else {
return err absPath, err = filepath.Abs(relPath)
if err != nil {
return absPath, err
}
} }
logrus.Debugf("resolved %s to %s to read the ssh identity file", relPath, absPath)
return absPath, nil
} }

View File

@ -1,38 +0,0 @@
package test
import (
"log"
"os"
"github.com/sirupsen/logrus"
)
// RmServerAppRecipe deletes the test server / app / recipe.
func RmServerAppRecipe() {
testAppLink := os.ExpandEnv("$HOME/.abra/servers/foo.com")
if err := os.Remove(testAppLink); err != nil {
logrus.Fatal(err)
}
testRecipeLink := os.ExpandEnv("$HOME/.abra/recipes/test")
if err := os.Remove(testRecipeLink); err != nil {
logrus.Fatal(err)
}
}
// MkServerAppRecipe symlinks the test server / app / recipe.
func MkServerAppRecipe() {
RmServerAppRecipe()
testAppDir := os.ExpandEnv("$PWD/../../tests/resources/testapp")
testAppLink := os.ExpandEnv("$HOME/.abra/servers/foo.com")
if err := os.Symlink(testAppDir, testAppLink); err != nil {
log.Fatal(err)
}
testRecipeDir := os.ExpandEnv("$PWD/../../tests/resources/testrecipe")
testRecipeLink := os.ExpandEnv("$HOME/.abra/recipes/test")
if err := os.Symlink(testRecipeDir, testRecipeLink); err != nil {
log.Fatal(err)
}
}

View File

@ -2,15 +2,18 @@ package commandconn
import ( import (
"context" "context"
"fmt"
"net" "net"
"net/url" "net/url"
sshPkg "coopcloud.tech/abra/pkg/ssh"
"github.com/docker/cli/cli/connhelper" "github.com/docker/cli/cli/connhelper"
"github.com/docker/cli/cli/connhelper/ssh" "github.com/docker/cli/cli/connhelper/ssh"
"github.com/docker/cli/cli/context/docker" "github.com/docker/cli/cli/context/docker"
dCliContextStore "github.com/docker/cli/cli/context/store" dCliContextStore "github.com/docker/cli/cli/context/store"
dClient "github.com/docker/docker/client" dClient "github.com/docker/docker/client"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/sirupsen/logrus"
) )
// GetConnectionHelper returns Docker-specific connection helper for the given URL. // GetConnectionHelper returns Docker-specific connection helper for the given URL.
@ -33,6 +36,24 @@ func getConnectionHelper(daemonURL string, sshFlags []string) (*connhelper.Conne
return nil, errors.Wrap(err, "ssh host connection is not valid") return nil, errors.Wrap(err, "ssh host connection is not valid")
} }
if err := sshPkg.EnsureHostKey(ctxConnDetails.Host); err != nil {
return nil, err
}
hostConfig, err := sshPkg.GetHostConfig(
ctxConnDetails.Host,
ctxConnDetails.User,
ctxConnDetails.Port,
)
if err != nil {
return nil, err
}
if hostConfig.IdentityFile != "" {
msg := "discovered %s as identity file for %s, using for ssh connection"
logrus.Debugf(msg, hostConfig.IdentityFile, ctxConnDetails.Host)
sshFlags = append(sshFlags, fmt.Sprintf("-o IdentityFile=%s", hostConfig.IdentityFile))
}
return &connhelper.ConnectionHelper{ return &connhelper.ConnectionHelper{
Dialer: func(ctx context.Context, network, addr string) (net.Conn, error) { Dialer: func(ctx context.Context, network, addr string) (net.Conn, error) {
return New(ctx, "ssh", append(sshFlags, ctxConnDetails.Args("docker", "system", "dial-stdio")...)...) return New(ctx, "ssh", append(sshFlags, ctxConnDetails.Args("docker", "system", "dial-stdio")...)...)
@ -45,13 +66,13 @@ func getConnectionHelper(daemonURL string, sshFlags []string) (*connhelper.Conne
return nil, err return nil, err
} }
func NewConnectionHelper(daemonURL string) (*connhelper.ConnectionHelper, error) { func NewConnectionHelper(daemonURL string) *connhelper.ConnectionHelper {
helper, err := GetConnectionHelper(daemonURL) helper, err := GetConnectionHelper(daemonURL)
if err != nil { if err != nil {
return nil, err logrus.Fatal(err)
} }
return helper, nil return helper
} }
func getDockerEndpoint(host string) (docker.Endpoint, error) { func getDockerEndpoint(host string) (docker.Endpoint, error) {

View File

@ -420,12 +420,6 @@ func convertServiceSecrets(
return nil, err return nil, err
} }
// NOTE(d1): strip # length=... modifiers
if strings.Contains(obj.Name, "#") {
vals := strings.Split(obj.Name, "#")
obj.Name = strings.TrimSpace(vals[0])
}
file := swarm.SecretReferenceFileTarget(obj.File) file := swarm.SecretReferenceFileTarget(obj.File)
refs = append(refs, &swarm.SecretReference{ refs = append(refs, &swarm.SecretReference{
File: &file, File: &file,

View File

@ -35,21 +35,16 @@ func LoadComposefile(opts Deploy, appEnv map[string]string) (*composetypes.Confi
return nil, err return nil, err
} }
recipeName, exists := appEnv["RECIPE"]
if !exists {
recipeName, _ = appEnv["TYPE"]
}
unsupportedProperties := loader.GetUnsupportedProperties(dicts...) unsupportedProperties := loader.GetUnsupportedProperties(dicts...)
if len(unsupportedProperties) > 0 { if len(unsupportedProperties) > 0 {
logrus.Warnf("%s: ignoring unsupported options: %s", logrus.Warnf("%s: ignoring unsupported options: %s",
recipeName, strings.Join(unsupportedProperties, ", ")) appEnv["TYPE"], strings.Join(unsupportedProperties, ", "))
} }
deprecatedProperties := loader.GetDeprecatedProperties(dicts...) deprecatedProperties := loader.GetDeprecatedProperties(dicts...)
if len(deprecatedProperties) > 0 { if len(deprecatedProperties) > 0 {
logrus.Warnf("%s: ignoring deprecated options: %s", logrus.Warnf("%s: ignoring deprecated options: %s",
recipeName, propertyWarnings(deprecatedProperties)) appEnv["TYPE"], propertyWarnings(deprecatedProperties))
} }
return config, nil return config, nil
} }

View File

@ -8,9 +8,9 @@ import (
"strings" "strings"
"time" "time"
abraClient "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/upstream/convert" "coopcloud.tech/abra/pkg/upstream/convert"
"github.com/docker/cli/cli/command/service/progress" "github.com/docker/cli/cli/command/service/progress"
"github.com/docker/cli/cli/command/stack/formatter"
composetypes "github.com/docker/cli/cli/compose/types" composetypes "github.com/docker/cli/cli/compose/types"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
@ -18,7 +18,7 @@ import (
"github.com/docker/docker/api/types/swarm" "github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/api/types/versions" "github.com/docker/docker/api/types/versions"
"github.com/docker/docker/client" "github.com/docker/docker/client"
dockerClient "github.com/docker/docker/client" dockerclient "github.com/docker/docker/client"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
@ -57,10 +57,20 @@ func GetStackServices(ctx context.Context, dockerclient client.APIClient, namesp
} }
// GetDeployedServicesByLabel filters services by label // GetDeployedServicesByLabel filters services by label
func GetDeployedServicesByLabel(cl *dockerClient.Client, contextName string, label string) StackStatus { func GetDeployedServicesByLabel(contextName string, label string) StackStatus {
cl, err := abraClient.New(contextName)
if err != nil {
if strings.Contains(err.Error(), "does not exist") {
// No local context found, bail out gracefully
return StackStatus{[]swarm.Service{}, nil}
}
return StackStatus{[]swarm.Service{}, err}
}
ctx := context.Background()
filters := filters.NewArgs() filters := filters.NewArgs()
filters.Add("label", label) filters.Add("label", label)
services, err := cl.ServiceList(context.Background(), types.ServiceListOptions{Filters: filters}) services, err := cl.ServiceList(ctx, types.ServiceListOptions{Filters: filters})
if err != nil { if err != nil {
return StackStatus{[]swarm.Service{}, err} return StackStatus{[]swarm.Service{}, err}
} }
@ -68,8 +78,18 @@ func GetDeployedServicesByLabel(cl *dockerClient.Client, contextName string, lab
return StackStatus{services, nil} return StackStatus{services, nil}
} }
func GetAllDeployedServices(cl *dockerClient.Client, contextName string) StackStatus { func GetAllDeployedServices(contextName string) StackStatus {
services, err := cl.ServiceList(context.Background(), types.ServiceListOptions{Filters: getAllStacksFilter()}) cl, err := abraClient.New(contextName)
if err != nil {
if strings.Contains(err.Error(), "does not exist") {
// No local context found, bail out gracefully
return StackStatus{[]swarm.Service{}, nil}
}
return StackStatus{[]swarm.Service{}, err}
}
ctx := context.Background()
services, err := cl.ServiceList(ctx, types.ServiceListOptions{Filters: getAllStacksFilter()})
if err != nil { if err != nil {
return StackStatus{[]swarm.Service{}, err} return StackStatus{[]swarm.Service{}, err}
} }
@ -78,7 +98,7 @@ func GetAllDeployedServices(cl *dockerClient.Client, contextName string) StackSt
} }
// GetDeployedServicesByName filters services by name // GetDeployedServicesByName filters services by name
func GetDeployedServicesByName(ctx context.Context, cl *dockerClient.Client, stackName, serviceName string) StackStatus { func GetDeployedServicesByName(ctx context.Context, cl *dockerclient.Client, stackName, serviceName string) StackStatus {
filters := filters.NewArgs() filters := filters.NewArgs()
filters.Add("name", fmt.Sprintf("%s_%s", stackName, serviceName)) filters.Add("name", fmt.Sprintf("%s_%s", stackName, serviceName))
@ -91,7 +111,7 @@ func GetDeployedServicesByName(ctx context.Context, cl *dockerClient.Client, sta
} }
// IsDeployed chekcks whether an appp is deployed or not. // IsDeployed chekcks whether an appp is deployed or not.
func IsDeployed(ctx context.Context, cl *dockerClient.Client, stackName string) (bool, string, error) { func IsDeployed(ctx context.Context, cl *dockerclient.Client, stackName string) (bool, string, error) {
version := "unknown" version := "unknown"
isDeployed := false isDeployed := false
@ -122,7 +142,7 @@ func IsDeployed(ctx context.Context, cl *dockerClient.Client, stackName string)
} }
// pruneServices removes services that are no longer referenced in the source // pruneServices removes services that are no longer referenced in the source
func pruneServices(ctx context.Context, cl *dockerClient.Client, namespace convert.Namespace, services map[string]struct{}) { func pruneServices(ctx context.Context, cl *dockerclient.Client, namespace convert.Namespace, services map[string]struct{}) {
oldServices, err := GetStackServices(ctx, cl, namespace.Name()) oldServices, err := GetStackServices(ctx, cl, namespace.Name())
if err != nil { if err != nil {
logrus.Infof("Failed to list services: %s\n", err) logrus.Infof("Failed to list services: %s\n", err)
@ -138,7 +158,9 @@ func pruneServices(ctx context.Context, cl *dockerClient.Client, namespace conve
} }
// RunDeploy is the swarm implementation of docker stack deploy // RunDeploy is the swarm implementation of docker stack deploy
func RunDeploy(cl *dockerClient.Client, opts Deploy, cfg *composetypes.Config, appName string, dontWait bool) error { func RunDeploy(cl *dockerclient.Client, opts Deploy, cfg *composetypes.Config, appName string, dontWait bool) error {
ctx := context.Background()
if err := validateResolveImageFlag(&opts); err != nil { if err := validateResolveImageFlag(&opts); err != nil {
return err return err
} }
@ -148,7 +170,7 @@ func RunDeploy(cl *dockerClient.Client, opts Deploy, cfg *composetypes.Config, a
opts.ResolveImage = ResolveImageNever opts.ResolveImage = ResolveImageNever
} }
return deployCompose(context.Background(), cl, opts, cfg, appName, dontWait) return deployCompose(ctx, cl, opts, cfg, appName, dontWait)
} }
// validateResolveImageFlag validates the opts.resolveImage command line option // validateResolveImageFlag validates the opts.resolveImage command line option
@ -161,7 +183,7 @@ func validateResolveImageFlag(opts *Deploy) error {
} }
} }
func deployCompose(ctx context.Context, cl *dockerClient.Client, opts Deploy, config *composetypes.Config, appName string, dontWait bool) error { func deployCompose(ctx context.Context, cl *dockerclient.Client, opts Deploy, config *composetypes.Config, appName string, dontWait bool) error {
namespace := convert.NewNamespace(opts.Namespace) namespace := convert.NewNamespace(opts.Namespace)
if opts.Prune { if opts.Prune {
@ -219,7 +241,7 @@ func getServicesDeclaredNetworks(serviceConfigs []composetypes.ServiceConfig) ma
return serviceNetworks return serviceNetworks
} }
func validateExternalNetworks(ctx context.Context, client dockerClient.NetworkAPIClient, externalNetworks []string) error { func validateExternalNetworks(ctx context.Context, client dockerclient.NetworkAPIClient, externalNetworks []string) error {
for _, networkName := range externalNetworks { for _, networkName := range externalNetworks {
if !container.NetworkMode(networkName).IsUserDefined() { if !container.NetworkMode(networkName).IsUserDefined() {
// Networks that are not user defined always exist on all nodes as // Networks that are not user defined always exist on all nodes as
@ -228,7 +250,7 @@ func validateExternalNetworks(ctx context.Context, client dockerClient.NetworkAP
} }
network, err := client.NetworkInspect(ctx, networkName, types.NetworkInspectOptions{}) network, err := client.NetworkInspect(ctx, networkName, types.NetworkInspectOptions{})
switch { switch {
case dockerClient.IsErrNotFound(err): case dockerclient.IsErrNotFound(err):
return errors.Errorf("network %q is declared as external, but could not be found. You need to create a swarm-scoped network before the stack is deployed", networkName) return errors.Errorf("network %q is declared as external, but could not be found. You need to create a swarm-scoped network before the stack is deployed", networkName)
case err != nil: case err != nil:
return err return err
@ -239,7 +261,7 @@ func validateExternalNetworks(ctx context.Context, client dockerClient.NetworkAP
return nil return nil
} }
func createSecrets(ctx context.Context, cl *dockerClient.Client, secrets []swarm.SecretSpec) error { func createSecrets(ctx context.Context, cl *dockerclient.Client, secrets []swarm.SecretSpec) error {
for _, secretSpec := range secrets { for _, secretSpec := range secrets {
secret, _, err := cl.SecretInspectWithRaw(ctx, secretSpec.Name) secret, _, err := cl.SecretInspectWithRaw(ctx, secretSpec.Name)
switch { switch {
@ -248,7 +270,7 @@ func createSecrets(ctx context.Context, cl *dockerClient.Client, secrets []swarm
if err := cl.SecretUpdate(ctx, secret.ID, secret.Meta.Version, secretSpec); err != nil { if err := cl.SecretUpdate(ctx, secret.ID, secret.Meta.Version, secretSpec); err != nil {
return errors.Wrapf(err, "failed to update secret %s", secretSpec.Name) return errors.Wrapf(err, "failed to update secret %s", secretSpec.Name)
} }
case dockerClient.IsErrNotFound(err): case dockerclient.IsErrNotFound(err):
// secret does not exist, then we create a new one. // secret does not exist, then we create a new one.
logrus.Infof("Creating secret %s\n", secretSpec.Name) logrus.Infof("Creating secret %s\n", secretSpec.Name)
if _, err := cl.SecretCreate(ctx, secretSpec); err != nil { if _, err := cl.SecretCreate(ctx, secretSpec); err != nil {
@ -261,7 +283,7 @@ func createSecrets(ctx context.Context, cl *dockerClient.Client, secrets []swarm
return nil return nil
} }
func createConfigs(ctx context.Context, cl *dockerClient.Client, configs []swarm.ConfigSpec) error { func createConfigs(ctx context.Context, cl *dockerclient.Client, configs []swarm.ConfigSpec) error {
for _, configSpec := range configs { for _, configSpec := range configs {
config, _, err := cl.ConfigInspectWithRaw(ctx, configSpec.Name) config, _, err := cl.ConfigInspectWithRaw(ctx, configSpec.Name)
switch { switch {
@ -270,7 +292,7 @@ func createConfigs(ctx context.Context, cl *dockerClient.Client, configs []swarm
if err := cl.ConfigUpdate(ctx, config.ID, config.Meta.Version, configSpec); err != nil { if err := cl.ConfigUpdate(ctx, config.ID, config.Meta.Version, configSpec); err != nil {
return errors.Wrapf(err, "failed to update config %s", configSpec.Name) return errors.Wrapf(err, "failed to update config %s", configSpec.Name)
} }
case dockerClient.IsErrNotFound(err): case dockerclient.IsErrNotFound(err):
// config does not exist, then we create a new one. // config does not exist, then we create a new one.
logrus.Infof("Creating config %s\n", configSpec.Name) logrus.Infof("Creating config %s\n", configSpec.Name)
if _, err := cl.ConfigCreate(ctx, configSpec); err != nil { if _, err := cl.ConfigCreate(ctx, configSpec); err != nil {
@ -283,7 +305,7 @@ func createConfigs(ctx context.Context, cl *dockerClient.Client, configs []swarm
return nil return nil
} }
func createNetworks(ctx context.Context, cl *dockerClient.Client, namespace convert.Namespace, networks map[string]types.NetworkCreate) error { func createNetworks(ctx context.Context, cl *dockerclient.Client, namespace convert.Namespace, networks map[string]types.NetworkCreate) error {
existingNetworks, err := getStackNetworks(ctx, cl, namespace.Name()) existingNetworks, err := getStackNetworks(ctx, cl, namespace.Name())
if err != nil { if err != nil {
return err return err
@ -313,7 +335,7 @@ func createNetworks(ctx context.Context, cl *dockerClient.Client, namespace conv
func deployServices( func deployServices(
ctx context.Context, ctx context.Context,
cl *dockerClient.Client, cl *dockerclient.Client,
services map[string]swarm.ServiceSpec, services map[string]swarm.ServiceSpec,
namespace convert.Namespace, namespace convert.Namespace,
sendAuth bool, sendAuth bool,
@ -447,7 +469,7 @@ func getStackConfigs(ctx context.Context, dockerclient client.APIClient, namespa
// https://github.com/docker/cli/blob/master/cli/command/service/helpers.go // https://github.com/docker/cli/blob/master/cli/command/service/helpers.go
// https://github.com/docker/cli/blob/master/cli/command/service/progress/progress.go // https://github.com/docker/cli/blob/master/cli/command/service/progress/progress.go
func WaitOnService(ctx context.Context, cl *dockerClient.Client, serviceID, appName string) error { func WaitOnService(ctx context.Context, cl *dockerclient.Client, serviceID, appName string) error {
errChan := make(chan error, 1) errChan := make(chan error, 1)
pipeReader, pipeWriter := io.Pipe() pipeReader, pipeWriter := io.Pipe()
@ -485,37 +507,3 @@ If a service is failing to even start, try smoke out the error with:
`, appName, timeout, appName, appName, appName)) `, appName, timeout, appName, appName, appName))
} }
} }
// Copypasta from https://github.com/docker/cli/blob/master/cli/command/stack/swarm/list.go
// GetStacks lists the swarm stacks.
func GetStacks(cl *dockerClient.Client) ([]*formatter.Stack, error) {
services, err := cl.ServiceList(
context.Background(),
types.ServiceListOptions{Filters: getAllStacksFilter()})
if err != nil {
return nil, err
}
m := make(map[string]*formatter.Stack)
for _, service := range services {
labels := service.Spec.Labels
name, ok := labels[convert.LabelNamespace]
if !ok {
return nil, errors.Errorf("cannot get label %s for service %s",
convert.LabelNamespace, service.ID)
}
ztack, ok := m[name]
if !ok {
m[name] = &formatter.Stack{
Name: name,
Services: 1,
}
} else {
ztack.Services++
}
}
var stacks []*formatter.Stack
for _, stack := range m {
stacks = append(stacks, stack)
}
return stacks, nil
}

View File

@ -1,13 +1,3 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json"
"packageRules": [{
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
}],
"postUpdateOptions": [
"gomodTidy"
],
"ignoreDeps": [
"github.com/urfave/cli"
]
} }

View File

@ -1,7 +0,0 @@
function complete_abra_args
set -l cmd (commandline -poc) --generate-bash-completion
$cmd
end
complete -c abra -f -n "not __fish_seen_subcommand_from -h --help -v --version complete_abra_args" -a "(complete_abra_args)"
complete -c abra -f -s h -l help -d 'show help'
complete -c abra -f -s v -l version -d 'print the version'

View File

@ -1,8 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
ABRA_VERSION="0.6.0-beta" ABRA_VERSION="0.3.0-alpha"
ABRA_RELEASE_URL="https://git.coopcloud.tech/api/v1/repos/coop-cloud/abra/releases/tags/$ABRA_VERSION" ABRA_RELEASE_URL="https://git.coopcloud.tech/api/v1/repos/coop-cloud/abra/releases/tags/$ABRA_VERSION"
RC_VERSION="0.7.0-rc2-beta" RC_VERSION="0.4.0-alpha-rc4"
RC_VERSION_URL="https://git.coopcloud.tech/api/v1/repos/coop-cloud/abra/releases/tags/$RC_VERSION" RC_VERSION_URL="https://git.coopcloud.tech/api/v1/repos/coop-cloud/abra/releases/tags/$RC_VERSION"
for arg in "$@"; do for arg in "$@"; do
@ -44,17 +44,8 @@ function install_abra_release {
exit 1 exit 1
fi fi
ARCH=$(uname -m)
if [[ $ARCH =~ "aarch64" ]]; then PLATFORM=$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m)
ARCH="arm64"
elif [[ $ARCH =~ "armv5l" ]]; then
ARCH="armv5"
elif [[ $ARCH =~ "armv6l" ]]; then
ARCH="armv6"
elif [[ $ARCH =~ "armv7l" ]]; then
ARCH="armv7"
fi
PLATFORM=$(uname -s | tr '[:upper:]' '[:lower:]')_$ARCH
FILENAME="abra_"$ABRA_VERSION"_"$PLATFORM"" FILENAME="abra_"$ABRA_VERSION"_"$PLATFORM""
sed_command_rel='s/.*"assets":\[\{[^]]*"name":"'$FILENAME'"[^}]*"browser_download_url":"([^"]*)".*\].*/\1/p' sed_command_rel='s/.*"assets":\[\{[^]]*"name":"'$FILENAME'"[^}]*"browser_download_url":"([^"]*)".*\].*/\1/p'
sed_command_checksums='s/.*"assets":\[\{[^\]*"name":"checksums.txt"[^}]*"browser_download_url":"([^"]*)".*\].*/\1/p' sed_command_checksums='s/.*"assets":\[\{[^\]*"name":"checksums.txt"[^}]*"browser_download_url":"([^"]*)".*\].*/\1/p'

View File

@ -1,5 +0,0 @@
#!/bin/bash
set -ex
upx ./dist/abra_*/abra

View File

@ -1,4 +0,0 @@
GANDI_TOKEN=...
HCLOUD_TOKEN=...
REGISTRY_PASSWORD=...
REGISTRY_USERNAME=...

View File

@ -1 +0,0 @@
logs

View File

@ -1,28 +0,0 @@
# integration tests
> You need to be a member of Autonomic Co-op to run these tests, sorry!
`testfunctions.sh` contains the functions necessary to save and manipulate
logs. Run `test_all.sh logdir` to run tests specified in that file and save the
logs to `logdir`.
When creating new tests, make sure the test command is a one-liner (you can use
`;` to separate commands). Include `testfunctions.sh` and then write your tests
like this:
```
run_test '$ABRA other stuff here'
```
By default, the testing script will ask after every command if the execution
succeeded. If you reply `n`, it will log the test in the `logdir`. If you want
all tests to run without questions, run `export logall=yes` before executing
the test script.
To run tests, you'll need to prepare your environment:
```
cp .envrc.sample .envrc # fill out values...
direnv allow
./test_all.sh logs
```

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