init from gitlab
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
.DS_Store
|
||||
|
||||
ui/node_modules/
|
||||
ui/.svelte-kit
|
||||
ui/dist
|
||||
|
||||
.env
|
||||
.test_keyfile
|
||||
test.db
|
||||
shokku
|
||||
.idea
|
||||
23
.gitlab-ci.yml
Normal file
23
.gitlab-ci.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
docker-build:
|
||||
image: docker:latest
|
||||
stage: build
|
||||
services:
|
||||
- docker:dind
|
||||
before_script:
|
||||
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
|
||||
script:
|
||||
- |
|
||||
if [[ "$CI_COMMIT_BRANCH" == "$CI_DEFAULT_BRANCH" ]]; then
|
||||
tag=""
|
||||
echo "Running on default branch '$CI_DEFAULT_BRANCH': tag = 'latest'"
|
||||
else
|
||||
tag=":$CI_COMMIT_REF_SLUG"
|
||||
echo "Running on branch '$CI_COMMIT_BRANCH': tag = $tag"
|
||||
fi
|
||||
- docker build --pull -t "$CI_REGISTRY_IMAGE${tag}" .
|
||||
- docker push "$CI_REGISTRY_IMAGE${tag}"
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH
|
||||
exists:
|
||||
- Dockerfile
|
||||
when: manual
|
||||
20
Dockerfile
Normal file
20
Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM node:alpine AS npm_builder
|
||||
WORKDIR /app
|
||||
COPY ui ./ui
|
||||
RUN npm --prefix /app/ui install
|
||||
RUN npm --prefix /app/ui run build
|
||||
|
||||
FROM golang:alpine AS go_builder
|
||||
WORKDIR /app
|
||||
RUN apk update && apk add --no-cache git
|
||||
COPY cmd ./cmd
|
||||
COPY go.mod ./go.mod
|
||||
COPY internal ./internal
|
||||
COPY --from=npm_builder /app/ui/dist ./cmd/shokku/dist
|
||||
RUN go get -d -v ./...
|
||||
RUN go install -v ./...
|
||||
RUN GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o /go/bin/shokku ./cmd/shokku
|
||||
|
||||
FROM gcr.io/distroless/static:nonroot
|
||||
COPY --from=go_builder /go/bin/shokku /go/bin/shokku
|
||||
ENTRYPOINT ["/go/bin/shokku"]
|
||||
164
Justfile
Normal file
164
Justfile
Normal file
@@ -0,0 +1,164 @@
|
||||
set shell := ["bash", "-uc"]
|
||||
|
||||
ui_npm := 'npm --prefix ui'
|
||||
web_npm := 'npm --prefix web'
|
||||
shokku_image := 'texm/shokku:latest'
|
||||
dokku_image := 'dokku/dokku:0.30.2'
|
||||
dokku_container_name := 'shokku-dokku-dev'
|
||||
dokku_data_mount_dir := '/tmp/var/lib/dokku'
|
||||
dokku_ssh_host := '127.0.0.1'
|
||||
dokku_ssh_port := '3022'
|
||||
db_path := 'test.db'
|
||||
|
||||
host_keyfile := ".test_keyfile"
|
||||
container_keyfile := '/home/dokku/.ssh/authorized_keys'
|
||||
|
||||
reflex_script := "
|
||||
# Run dev server
|
||||
-s -r '\\.go$' -R '^ui/' -R '^\\.git$' -- just dev-backend
|
||||
|
||||
# Run ui dev server
|
||||
# Exclude everything since vite will hot reload
|
||||
-s -R '^.*' -- just dev-ui"
|
||||
|
||||
_default:
|
||||
@just --list
|
||||
|
||||
###
|
||||
# Public recipes
|
||||
###
|
||||
|
||||
# clean up resources from the backend and docker
|
||||
@clean: _clean-backend _clean-docker
|
||||
|
||||
# setup dependencies, bootstrap backend, create dokku container
|
||||
@setup: clean _install-dependencies _setup-dokku _setup-backend
|
||||
|
||||
# run the development environment (ui and backend)
|
||||
@dev:
|
||||
-echo -n '{{reflex_script}}' | reflex -d 'fancy' -c '-'
|
||||
|
||||
@dev-backend: _touch-cmd-dist _setup-dokku
|
||||
just _run-with-env go run ./cmd/shokku
|
||||
|
||||
@dev-ui:
|
||||
-{{ui_npm}} run dev
|
||||
|
||||
# build a static /cmd/shokku binary
|
||||
@build:
|
||||
{{ui_npm}} run build
|
||||
mv ./ui/dist ./cmd/shokku
|
||||
# GOOS=linux GOARCH=amd64 go build -o shokku ./cmd/shokku
|
||||
-go build -o shokku ./cmd/shokku
|
||||
-rm -r ./cmd/shokku/dist
|
||||
|
||||
@format:
|
||||
go fmt ./...
|
||||
{{ui_npm}} run prettier
|
||||
|
||||
# run the website development server
|
||||
@dev-web:
|
||||
{{web_npm}} install
|
||||
{{web_npm}} run dev
|
||||
|
||||
# build the docker image and tag as latest
|
||||
build-docker:
|
||||
docker build -t "{{shokku_image}}" .
|
||||
|
||||
# run the docker image 'texm/shokku:latest' with environment vars set
|
||||
run-docker:
|
||||
@docker run -d \
|
||||
-e DOKKU_SSH_HOST='{{dokku_ssh_host}}' \
|
||||
-e DOKKU_SSH_PORT='{{dokku_ssh_port}}' \
|
||||
-e DB_PATH='{{db_path}}' \
|
||||
{{shokku_image}}
|
||||
|
||||
###
|
||||
# Private helper recipes
|
||||
###
|
||||
|
||||
## dependencies
|
||||
@_install-dependencies:
|
||||
go install github.com/cespare/reflex@latest
|
||||
{{ui_npm}} install > /dev/null
|
||||
go mod tidy
|
||||
##
|
||||
|
||||
## helpers
|
||||
@_touch-cmd-dist:
|
||||
mkdir -p ./cmd/shokku/dist && touch ./cmd/shokku/dist/bleh
|
||||
|
||||
@_run-with-env +CMD:
|
||||
DOKKU_SSH_HOST='{{dokku_ssh_host}}' \
|
||||
DOKKU_SSH_PORT='{{dokku_ssh_port}}' \
|
||||
DB_PATH='{{db_path}}' \
|
||||
DEBUG_MODE=true \
|
||||
{{CMD}}
|
||||
##
|
||||
|
||||
## builds & cleaning
|
||||
@_clean-backend:
|
||||
go clean
|
||||
-rm -r ./cmd/shokku/dist &> /dev/null
|
||||
-rm {{db_path}} &> /dev/null
|
||||
-rm {{host_keyfile}} &> /dev/null
|
||||
|
||||
@_clean-docker:
|
||||
-docker rm -f {{dokku_container_name}} &> /dev/null
|
||||
-sudo rm -r {{dokku_data_mount_dir}} &> /dev/null
|
||||
##
|
||||
|
||||
## Setup
|
||||
|
||||
@_setup-backend: _touch-cmd-dist
|
||||
just _run-with-env go run ./cmd/shokku bootstrap > {{host_keyfile}}
|
||||
just _add-dokku-ssh-key;
|
||||
|
||||
_setup-dokku:
|
||||
#!/bin/bash
|
||||
if [[ -z $(docker ps -a | grep '{{dokku_container_name}}') ]]; then
|
||||
just _run-dokku-container;
|
||||
just _install-dokku-plugins;
|
||||
elif [[ `docker container inspect -f '{{{{.State.Running}}' '{{dokku_container_name}}'` == 'false' ]]; then
|
||||
docker start {{dokku_container_name}};
|
||||
fi
|
||||
|
||||
@_run-dokku-container:
|
||||
docker run -d \
|
||||
--env DOKKU_HOSTNAME=dokku.me \
|
||||
--env DOKKU_HOST_ROOT={{dokku_data_mount_dir}}/home/dokku \
|
||||
--env DOKKU_LIB_HOST_ROOT={{dokku_data_mount_dir}}/var/lib/dokku \
|
||||
--name {{dokku_container_name}} \
|
||||
--publish {{dokku_ssh_port}}:22 \
|
||||
--volume {{dokku_data_mount_dir}}:/mnt/dokku \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
{{dokku_image}} > /dev/null
|
||||
|
||||
if [[ "{{ os() }}" == 'macos' ]]; then \
|
||||
docker exec shokku-dokku-dev bash -c \
|
||||
'groupmod -g 99 systemd-timesync && groupmod -g 101 docker'; fi
|
||||
|
||||
@_install-dokku-plugins:
|
||||
echo "installing dokku plugins"
|
||||
for plugin in redis postgres mongo mysql letsencrypt; do \
|
||||
echo "installing $plugin"; \
|
||||
docker exec {{dokku_container_name}} bash -c \
|
||||
"dokku plugin:install https://github.com/dokku/dokku-$plugin.git" \
|
||||
> /dev/null; \
|
||||
done
|
||||
|
||||
# to fix plugins: https://github.com/dokku/dokku/issues/5004
|
||||
# -docker exec {{dokku_container_name}} bash -c "rm -r /mnt/dokku/services" 2&> /dev/null
|
||||
# -docker exec {{dokku_container_name}} bash -c "mv /var/lib/dokku/services /mnt/dokku/"
|
||||
# -docker exec {{dokku_container_name}} bash -c "ln -s /mnt/dokku/services/ /var/lib/dokku/services"
|
||||
|
||||
@_add-dokku-ssh-key:
|
||||
docker exec {{dokku_container_name}} bash -c \
|
||||
'cd /home/ \
|
||||
&& rm -f {{container_keyfile}} \
|
||||
&& touch {{container_keyfile}} \
|
||||
&& chown dokku:dokku {{container_keyfile}}'
|
||||
|
||||
docker exec {{dokku_container_name}} bash -c \
|
||||
"echo '$(cat {{host_keyfile}})' | dokku ssh-keys:add admin" > /dev/null
|
||||
##
|
||||
98
bootstrap.sh
Normal file
98
bootstrap.sh
Normal file
@@ -0,0 +1,98 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# This script installs and sets up shokku with a few prerequisites:
|
||||
# dokku must be installed
|
||||
# a global domain must be set
|
||||
# letsencrypt must be configured and enabled with global email set
|
||||
|
||||
DOKKU_STORAGE_DIR="/var/lib/dokku/data/storage/"
|
||||
SHOKKU_DATA_DIR="$DOKKU_STORAGE_DIR/shokku"
|
||||
SHOKKU_APP_DATA_MOUNT_PATH="$SHOKKU_DATA_DIR:/data"
|
||||
SHOKKU_VERSION=${SHOKKU_VERSION:-"latest"}
|
||||
SHOKKU_IMAGE="registry.gitlab.com/texm/shokku:$SHOKKU_VERSION"
|
||||
|
||||
SHOKKU_DOKKU_USER="shokkuadmin"
|
||||
DISTROLESS_NONROOT_UID="65532"
|
||||
|
||||
clean-shokku() {
|
||||
echo "=> checking for existing resources"
|
||||
|
||||
if dokku apps:exists shokku &> /dev/null; then
|
||||
echo "==> destroying old dokku app"
|
||||
dokku apps:destroy --force shokku &> /dev/null
|
||||
fi
|
||||
|
||||
if dokku ssh-keys:list "$SHOKKU_DOKKU_USER" &> /dev/null; then
|
||||
echo "==> removing existing dokku ssh key"
|
||||
dokku ssh-keys:remove $SHOKKU_DOKKU_USER;
|
||||
fi
|
||||
}
|
||||
|
||||
create-shokku-app() {
|
||||
clean-shokku
|
||||
echo "=> pulling version '$SHOKKU_VERSION'"
|
||||
HOST_SSH_PORT=$(grep "Port " /etc/ssh/sshd_config | awk '{ print $2 }')
|
||||
docker pull "$SHOKKU_IMAGE" &> /dev/null
|
||||
SHOKKU_IMAGE_DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "$SHOKKU_IMAGE")
|
||||
|
||||
echo "=> creating & configuring app"
|
||||
dokku apps:create shokku &> /dev/null
|
||||
dokku docker-options:add shokku deploy \
|
||||
"--add-host=host.docker.internal:host-gateway" &> /dev/null
|
||||
dokku config:set shokku \
|
||||
DOKKU_SSH_HOST='host.docker.internal' \
|
||||
DOKKU_SSH_PORT="$HOST_SSH_PORT" &> /dev/null
|
||||
|
||||
echo "==> creating storage"
|
||||
dokku storage:ensure-directory shokku --chown false &> /dev/null
|
||||
dokku storage:mount shokku "$SHOKKU_APP_DATA_MOUNT_PATH" &> /dev/null
|
||||
chown -R "$DISTROLESS_NONROOT_UID":"$DISTROLESS_NONROOT_UID" "$SHOKKU_DATA_DIR" &> /dev/null
|
||||
|
||||
echo "==> bootstrapping"
|
||||
dokku config:set shokku DOKKU_SKIP_DEPLOY=true &> /dev/null
|
||||
dokku git:from-image shokku "$SHOKKU_IMAGE_DIGEST" &> /dev/null
|
||||
|
||||
shokku_ssh_key=$(dokku run shokku bootstrap) &> /dev/null
|
||||
echo "$shokku_ssh_key" | dokku ssh-keys:add "$SHOKKU_DOKKU_USER" &> /dev/null
|
||||
|
||||
echo "==> deploying"
|
||||
dokku config:unset shokku DOKKU_SKIP_DEPLOY &> /dev/null
|
||||
|
||||
echo "==> enabling letsencrypt"
|
||||
dokku letsencrypt:enable shokku &> /dev/null
|
||||
}
|
||||
|
||||
main() {
|
||||
if [[ "$(id -u)" != "0" ]]; then
|
||||
echo "This script must be run as root" 1>&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v dokku &> /dev/null; then
|
||||
echo "Please install dokku first using the instructions at https://dokku.com" 1>&2
|
||||
exit
|
||||
fi
|
||||
|
||||
if ! dokku plugin:installed letsencrypt; then
|
||||
echo "Please setup letsencrypt using the instructions at https://dokku.com/docs/deployment/application-deployment/#setting-up-ssl" 1>&2
|
||||
exit
|
||||
fi
|
||||
|
||||
for plugin in redis postgres mongo mysql; do
|
||||
if ! dokku plugin:installed $plugin; then
|
||||
echo "=> Installing plugin $plugin"
|
||||
dokku plugin:install https://github.com/dokku/dokku-$plugin.git $plugin &> /dev/null
|
||||
fi
|
||||
done
|
||||
|
||||
create-shokku-app
|
||||
|
||||
shokku_app_domain=$(dokku domains:report shokku --domains-app-vhosts)
|
||||
shokku_setup_key=$(dokku logs -q shokku | grep setup_key | jq ".setup_key")
|
||||
|
||||
echo "=> shokku installed and running "
|
||||
echo "--- proceed with setup using key $shokku_setup_key at https://$shokku_app_domain ---"
|
||||
}
|
||||
|
||||
main
|
||||
58
go.mod
Normal file
58
go.mod
Normal file
@@ -0,0 +1,58 @@
|
||||
module gitlab.com/texm/shokku
|
||||
|
||||
go 1.18
|
||||
|
||||
require (
|
||||
github.com/bradleyfalzon/ghinstallation/v2 v2.1.0
|
||||
github.com/glebarez/sqlite v1.5.0
|
||||
github.com/go-playground/validator/v10 v10.11.0
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible
|
||||
github.com/google/go-github/v48 v48.0.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/labstack/echo/v4 v4.8.0
|
||||
github.com/pquerna/otp v1.4.0
|
||||
github.com/rs/zerolog v1.27.0
|
||||
github.com/sethvargo/go-envconfig v0.8.2
|
||||
github.com/stretchr/testify v1.7.1
|
||||
github.com/texm/dokku-go v0.0.0-20230313121650-17f2109811a5
|
||||
golang.org/x/crypto v0.7.0
|
||||
golang.org/x/oauth2 v0.2.0
|
||||
gorm.io/gorm v1.24.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/glebarez/go-sqlite v1.19.1 // indirect
|
||||
github.com/go-playground/locales v0.14.0 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.0 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.4.2 // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/google/go-github/v45 v45.2.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/labstack/gommon v0.3.1 // indirect
|
||||
github.com/leodido/go-urn v1.2.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.16 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.1 // indirect
|
||||
golang.org/x/net v0.8.0 // indirect
|
||||
golang.org/x/sys v0.6.0 // indirect
|
||||
golang.org/x/text v0.8.0 // indirect
|
||||
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/grpc v1.41.0 // indirect
|
||||
google.golang.org/protobuf v1.28.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
||||
modernc.org/libc v1.19.0 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.4.0 // indirect
|
||||
modernc.org/sqlite v1.19.1 // indirect
|
||||
)
|
||||
323
go.sum
Normal file
323
go.sum
Normal file
@@ -0,0 +1,323 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/Microsoft/go-winio v0.4.17 h1:iT12IBVClFevaf8PuVyi3UmZOVh4OqnaLxDTW2O6j3w=
|
||||
github.com/Microsoft/hcsshim v0.8.23 h1:47MSwtKGXet80aIn+7h4YI6fwPmwIghAnsx2aOUrG2M=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs=
|
||||
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bradleyfalzon/ghinstallation/v2 v2.1.0 h1:5+NghM1Zred9Z078QEZtm28G/kfDfZN/92gkDlLwGVA=
|
||||
github.com/bradleyfalzon/ghinstallation/v2 v2.1.0/go.mod h1:Xg3xPRN5Mcq6GDqeUVhFbjEWMb4JHCyWEeeBGEYQoTU=
|
||||
github.com/cenkalti/backoff/v4 v4.1.2 h1:6Yo7N8UP2K6LWZnW94DLVSSrbobcWdVzAYOisuDPIFo=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||
github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/containerd/cgroups v1.0.1 h1:iJnMvco9XGvKUvNQkv88bE4uJXxRQH18efbKo9w5vHQ=
|
||||
github.com/containerd/containerd v1.5.9 h1:rs6Xg1gtIxaeyG+Smsb/0xaSDu1VgFhOCKBXxMxbsF4=
|
||||
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug=
|
||||
github.com/docker/docker v20.10.11+incompatible h1:OqzI/g/W54LczvhnccGqniFoQghHx3pklbLuhfXpqGo=
|
||||
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
|
||||
github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/glebarez/go-sqlite v1.19.1 h1:o2XhjyR8CQ2m84+bVz10G0cabmG0tY4sIMiCbrcUTrY=
|
||||
github.com/glebarez/go-sqlite v1.19.1/go.mod h1:9AykawGIyIcxoSfpYWiX1SgTNHTNsa/FVc75cDkbp4M=
|
||||
github.com/glebarez/sqlite v1.5.0 h1:+8LAEpmywqresSoGlqjjT+I9m4PseIM3NcerIJ/V7mk=
|
||||
github.com/glebarez/sqlite v1.5.0/go.mod h1:0wzXzTvfVJIN2GqRhCdMbnYd+m+aH5/QV7B30rM6NgY=
|
||||
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
|
||||
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
|
||||
github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
|
||||
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
|
||||
github.com/go-playground/validator/v10 v10.11.0 h1:0W+xRM511GY47Yy3bZUbJVitCNg2BOGlCyvTqsp/xIw=
|
||||
github.com/go-playground/validator/v10 v10.11.0/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-github/v45 v45.2.0 h1:5oRLszbrkvxDDqBCNj2hjDZMKmvexaZ1xw/FCD+K3FI=
|
||||
github.com/google/go-github/v45 v45.2.0/go.mod h1:FObaZJEDSTa/WGCzZ2Z3eoCDXWJKMenWWTrd8jrta28=
|
||||
github.com/google/go-github/v48 v48.0.0 h1:9H5fWVXFK6ZsRriyPbjtnFAkJnoj0WKFtTYfpCRrTm8=
|
||||
github.com/google/go-github/v48 v48.0.0/go.mod h1:dDlehKBDo850ZPvCTK0sEqTCVWcrGl2LcDiajkYi89Y=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/labstack/echo/v4 v4.8.0 h1:wdc6yKVaHxkNOEdz4cRZs1pQkwSXPiRjq69yWP4QQS8=
|
||||
github.com/labstack/echo/v4 v4.8.0/go.mod h1:xkCDAdFCIf8jsFQ5NnbK7oqaF/yU1A1X20Ltm0OvSks=
|
||||
github.com/labstack/gommon v0.3.1 h1:OomWaJXm7xR6L1HmEtGyQf26TEn7V6X88mktX9kee9o=
|
||||
github.com/labstack/gommon v0.3.1/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
|
||||
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
|
||||
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
|
||||
github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls=
|
||||
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/moby/sys/mount v0.2.0 h1:WhCW5B355jtxndN5ovugJlMFJawbUODuW8fSnEH6SSM=
|
||||
github.com/moby/sys/mountinfo v0.5.0 h1:2Ks8/r6lopsxWi9m58nlwjaeSzUX9iiL1vj5qB/9ObI=
|
||||
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 h1:dcztxKSvZ4Id8iPpHERQBbIJfabdt4wUm5qy3wOL2Zc=
|
||||
github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c h1:nXxl5PrvVm2L/wCy8dQu6DMTwH4oIuGN8GJDAlqDdVE=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM=
|
||||
github.com/opencontainers/runc v1.0.2 h1:opHZMaswlyxz1OuGpBE53Dwe4/xF7EZTY0A2L/FpCOg=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
|
||||
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.27.0 h1:1T7qCieN22GVc8S4Q2yuexzBb1EqjbgjSH9RohbMjKs=
|
||||
github.com/rs/zerolog v1.27.0/go.mod h1:7frBqO0oezxmnO7GF86FY++uy8I0Tk/If5ni1G9Qc0U=
|
||||
github.com/sethvargo/go-envconfig v0.8.2 h1:DDUVuG21RMgeB/bn4leclUI/837y6cQCD4w8hb5797k=
|
||||
github.com/sethvargo/go-envconfig v0.8.2/go.mod h1:Iz1Gy1Sf3T64TQlJSvee81qDhf7YIlt8GMUX6yyNFs0=
|
||||
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/testcontainers/testcontainers-go v0.13.0 h1:OUujSlEGsXVo/ykPVZk3KanBNGN0TYb/7oKIPVn15JA=
|
||||
github.com/texm/dokku-go v0.0.0-20230313121650-17f2109811a5 h1:aqhKgNTHPa99dEGAGFjBIjQyEtP33sDd9eNrd8MnVBU=
|
||||
github.com/texm/dokku-go v0.0.0-20230313121650-17f2109811a5/go.mod h1:SmmgKvqbYdG9PJK+pwwZt9ZASXWXuNqtXRmSVINsM3o=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
|
||||
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.opencensus.io v0.22.3 h1:8sGtKOrtQqkN1bp2AtX+misvLIlOmsEsNd+9NIcPEm8=
|
||||
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
|
||||
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.2.0 h1:GtQkldQ9m7yvzCL1V+LrYow3Khe0eJH0w7RbX/VbaIU=
|
||||
golang.org/x/oauth2 v0.2.0/go.mod h1:Cwn6afJ8jrQwYMxQDTpISoXmXW9I6qF6vDeuuoX3Ibs=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 h1:ftMN5LMiBFjbzleLqtoBZk7KdJwhuybIU+FckUHgoyQ=
|
||||
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a h1:pOwg4OoaRYScjmR4LlLgdtnyoHYTSAVhhqe5uPdpII8=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
|
||||
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||
google.golang.org/grpc v1.41.0 h1:f+PlOh7QV4iIJkPrx5NQ7qaNGFQ3OTse67yaDHfju4E=
|
||||
google.golang.org/grpc v1.41.0/go.mod h1:U3l9uK9J0sini8mHphKoXyaqDA/8VyGnDee1zzIUK6k=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
|
||||
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/gorm v1.24.0 h1:j/CoiSm6xpRpmzbFJsQHYj+I8bGYWLXVHeYEyyKlF74=
|
||||
gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
|
||||
modernc.org/cc/v3 v3.37.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
|
||||
modernc.org/cc/v3 v3.38.1/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
|
||||
modernc.org/ccgo/v3 v3.0.0-20220904174949-82d86e1b6d56/go.mod h1:YSXjPL62P2AMSxBphRHPn7IkzhVHqkvOnRKAKh+W6ZI=
|
||||
modernc.org/ccgo/v3 v3.0.0-20220910160915-348f15de615a/go.mod h1:8p47QxPkdugex9J4n9P2tLZ9bK01yngIVp00g4nomW0=
|
||||
modernc.org/ccgo/v3 v3.16.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo=
|
||||
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
|
||||
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
|
||||
modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0=
|
||||
modernc.org/libc v1.17.4/go.mod h1:WNg2ZH56rDEwdropAJeZPQkXmDwh+JCA1s/htl6r2fA=
|
||||
modernc.org/libc v1.18.0/go.mod h1:vj6zehR5bfc98ipowQOM2nIDUZnVew/wNC/2tOGS+q0=
|
||||
modernc.org/libc v1.19.0 h1:bXyVhGQg6KIClTr8FMVIDPl7jtbcs7aS5WP7vLDaxPs=
|
||||
modernc.org/libc v1.19.0/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
|
||||
modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
|
||||
modernc.org/memory v1.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/memory v1.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk=
|
||||
modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sqlite v1.19.1 h1:8xmS5oLnZtAK//vnd4aTVj8VOeTAccEFOtUnIzfSw+4=
|
||||
modernc.org/sqlite v1.19.1/go.mod h1:UfQ83woKMaPW/ZBruK0T7YaFCrI+IE0LeWVY6pmnVms=
|
||||
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
|
||||
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
||||
modernc.org/tcl v1.14.0/go.mod h1:gQ7c1YPMvryCHCcmf8acB6VPabE59QBeuRQLL7cTUlM=
|
||||
modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
modernc.org/z v1.6.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ=
|
||||
41
internal/env/env.go
vendored
Normal file
41
internal/env/env.go
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
package env
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/texm/dokku-go"
|
||||
"gitlab.com/texm/shokku/internal/server/auth"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const Version = "0.0.1"
|
||||
|
||||
type Env struct {
|
||||
Version string
|
||||
Router *echo.Echo
|
||||
Dokku *dokku.SSHClient
|
||||
DB *gorm.DB
|
||||
Auth auth.Authenticator
|
||||
|
||||
DebugMode bool
|
||||
SetupCompleted bool
|
||||
}
|
||||
|
||||
func New(debugMode bool) *Env {
|
||||
return &Env{
|
||||
DebugMode: debugMode,
|
||||
Version: Version,
|
||||
}
|
||||
}
|
||||
|
||||
type handlerFunc func(env *Env, c echo.Context) error
|
||||
|
||||
func (e *Env) H(f handlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error { return f(e, c) }
|
||||
}
|
||||
|
||||
func (e *Env) Shutdown() error {
|
||||
if err := e.Dokku.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
42
internal/env/mock.go
vendored
Normal file
42
internal/env/mock.go
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
package env
|
||||
|
||||
import (
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/texm/dokku-go"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func NewTestingEnvironment() *Env {
|
||||
router := echo.New()
|
||||
router.Validator = dto.NewRequestValidator()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
panic(err.Error())
|
||||
}
|
||||
|
||||
dokkuClient := &mockDokkuClient{}
|
||||
|
||||
return &Env{
|
||||
Router: router,
|
||||
DB: db,
|
||||
DebugMode: true,
|
||||
Dokku: &dokkuClient.SSHClient,
|
||||
}
|
||||
}
|
||||
|
||||
type mockDokkuClient struct {
|
||||
dokku.SSHClient
|
||||
|
||||
returnVal string
|
||||
}
|
||||
|
||||
func (mc *mockDokkuClient) Exec(cmd string) (string, error) {
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
func (mc *mockDokkuClient) SetReturnValue(val string) {
|
||||
mc.returnVal = val
|
||||
}
|
||||
22
internal/models/auth.go
Normal file
22
internal/models/auth.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package models
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
type User struct {
|
||||
gorm.Model
|
||||
Name string `gorm:"unique"`
|
||||
Source string
|
||||
SSHKeys []SSHKey
|
||||
|
||||
PasswordHash []byte
|
||||
TotpEnabled bool
|
||||
TotpSecret string
|
||||
RecoveryCode string
|
||||
}
|
||||
|
||||
type SSHKey struct {
|
||||
gorm.Model
|
||||
UserID uint
|
||||
GithubID int64 `gorm:"unique"`
|
||||
Key string
|
||||
}
|
||||
28
internal/models/dokku.go
Normal file
28
internal/models/dokku.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package models
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
type App struct {
|
||||
gorm.Model
|
||||
Name string
|
||||
IsSetup bool
|
||||
SetupMethod string
|
||||
}
|
||||
|
||||
type AppSetupConfig struct {
|
||||
gorm.Model
|
||||
AppID uint
|
||||
DeployBranch string
|
||||
RepoURL string
|
||||
RepoGitRef string
|
||||
Image string
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
gorm.Model
|
||||
Name string
|
||||
Type string
|
||||
BackupAuthSet bool
|
||||
BackupEncryptionSet bool
|
||||
BackupBucket string
|
||||
}
|
||||
21
internal/models/github.go
Normal file
21
internal/models/github.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type GithubApp struct {
|
||||
gorm.Model
|
||||
AppId int64
|
||||
NodeId string
|
||||
ClientId string
|
||||
Name string
|
||||
Slug string
|
||||
ClientSecret string
|
||||
WebhookSecret string
|
||||
PEM string
|
||||
}
|
||||
|
||||
func (GithubApp) TableName() string {
|
||||
return "github_app"
|
||||
}
|
||||
25
internal/models/server.go
Normal file
25
internal/models/server.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"gitlab.com/texm/shokku/internal/server/auth"
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
gorm.Model
|
||||
IsSetup bool
|
||||
SetupKey string
|
||||
AuthMethod auth.Method
|
||||
LastSync time.Time
|
||||
}
|
||||
|
||||
func (Server) TableName() string {
|
||||
return "server"
|
||||
}
|
||||
|
||||
type ServerSecrets struct {
|
||||
gorm.Model
|
||||
DokkuSSHKeyGob []byte
|
||||
SigningKey []byte
|
||||
}
|
||||
103
internal/server/api/apps/builds.go
Normal file
103
internal/server/api/apps/builds.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/texm/dokku-go"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func GetAppBuilder(e *env.Env, c echo.Context) error {
|
||||
var req dto.GetAppBuilderRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
report, err := e.Dokku.GetAppBuilderReport(req.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting app builder: %w", err)
|
||||
}
|
||||
|
||||
selectedBuilder := report.ComputedSelectedBuilder
|
||||
if selectedBuilder == "" {
|
||||
selectedBuilder = "auto"
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.GetAppBuilderResponse{
|
||||
Selected: selectedBuilder,
|
||||
})
|
||||
}
|
||||
|
||||
func SetAppBuilder(e *env.Env, c echo.Context) error {
|
||||
var req dto.SetAppBuilderRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
builders := map[string]dokku.AppBuilder{
|
||||
"auto": "",
|
||||
"dockerfile": dokku.AppBuilderDockerfile,
|
||||
"herokuish": dokku.AppBuilderHerokuish,
|
||||
"null": dokku.AppBuilderNull,
|
||||
"buildpack": dokku.AppBuilderPack,
|
||||
"lambda": dokku.AppBuilderLambda,
|
||||
}
|
||||
chosenBuilder, supported := builders[req.Builder]
|
||||
if !supported {
|
||||
return echo.NewHTTPError(http.StatusBadRequest,
|
||||
fmt.Sprintf("unsupported builder '%s'", req.Builder))
|
||||
}
|
||||
|
||||
err := e.Dokku.SetAppSelectedBuilder(req.Name, chosenBuilder)
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting app builder: %w", err)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func GetAppBuildDirectory(e *env.Env, c echo.Context) error {
|
||||
var req dto.GetAppBuildDirectoryRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
report, err := e.Dokku.GetAppBuilderReport(req.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting app build dir: %w", err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.GetAppBuildDirectoryResponse{
|
||||
Directory: report.ComputedBuildDir,
|
||||
})
|
||||
}
|
||||
|
||||
func SetAppBuildDirectory(e *env.Env, c echo.Context) error {
|
||||
var req dto.SetAppBuildDirectoryRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
err := e.Dokku.SetAppBuilderProperty(req.Name, dokku.BuilderPropertyBuildDir, req.Directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting app build dir: %w", err)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func ClearAppBuildDirectory(e *env.Env, c echo.Context) error {
|
||||
var req dto.ClearAppBuildDirectoryRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
err := e.Dokku.SetAppBuilderProperty(req.Name, dokku.BuilderPropertyBuildDir, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("clearing app build dir: %w", err)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
38
internal/server/api/apps/config.go
Normal file
38
internal/server/api/apps/config.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func GetAppConfig(e *env.Env, c echo.Context) error {
|
||||
var req dto.GetAppConfigRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
config, err := e.Dokku.GetAppConfig(req.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting app config: %w", err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.GetAppConfigResponse{
|
||||
Config: config,
|
||||
})
|
||||
}
|
||||
|
||||
func SetAppConfig(e *env.Env, c echo.Context) error {
|
||||
var req dto.SetAppConfigRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
if err := e.Dokku.SetAppConfigValues(req.Name, req.Config, false); err != nil {
|
||||
return fmt.Errorf("setting app config: %w", err)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
97
internal/server/api/apps/domains.go
Normal file
97
internal/server/api/apps/domains.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func GetAppDomainsReport(e *env.Env, c echo.Context) error {
|
||||
var req dto.GetAppDomainsReportRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return echo.ErrBadRequest
|
||||
}
|
||||
|
||||
report, err := e.Dokku.GetAppDomainsReport(req.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting app domains report: %w", err)
|
||||
}
|
||||
|
||||
if len(report.AppDomains) == 0 {
|
||||
report.AppDomains = make([]string, 0)
|
||||
}
|
||||
|
||||
if len(report.GlobalDomains) == 0 {
|
||||
report.GlobalDomains = make([]string, 0)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.GetAppDomainsReportResponse{
|
||||
Domains: report.AppDomains,
|
||||
Enabled: report.AppEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
func SetAppDomainsEnabled(e *env.Env, c echo.Context) error {
|
||||
var req dto.SetAppDomainsEnabledRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return echo.ErrBadRequest
|
||||
}
|
||||
|
||||
var err error
|
||||
if req.Enabled {
|
||||
err = e.Dokku.EnableAppDomains(req.Name)
|
||||
} else {
|
||||
err = e.Dokku.DisableAppDomains(req.Name)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting app domains enabled: %w", err)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func GetAppLetsEncryptEnabled(e *env.Env, c echo.Context) error {
|
||||
var req dto.GetAppLetsEncryptEnabledRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return echo.ErrBadRequest
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
func SetAppLetsEncryptEnabled(e *env.Env, c echo.Context) error {
|
||||
var req dto.SetAppLetsEncryptEnabledRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return echo.ErrBadRequest
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
func AddAppDomain(e *env.Env, c echo.Context) error {
|
||||
var req dto.AlterAppDomainRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return echo.ErrBadRequest
|
||||
}
|
||||
|
||||
if err := e.Dokku.AddAppDomain(req.Name, req.Domain); err != nil {
|
||||
return fmt.Errorf("adding app domain: %w", err)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func RemoveAppDomain(e *env.Env, c echo.Context) error {
|
||||
var req dto.AlterAppDomainRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
if err := e.Dokku.RemoveAppDomain(req.Name, req.Domain); err != nil {
|
||||
return fmt.Errorf("removing app domain: %w", err)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
415
internal/server/api/apps/management.go
Normal file
415
internal/server/api/apps/management.go
Normal file
@@ -0,0 +1,415 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/models"
|
||||
"gitlab.com/texm/shokku/internal/server/commands"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/texm/dokku-go"
|
||||
)
|
||||
|
||||
const FilteredApp = "shokku"
|
||||
|
||||
func lookupDBAppByName(e *env.Env, name string) (*models.App, error) {
|
||||
dbApp := models.App{Name: name}
|
||||
res := e.DB.Where("name = ?", name).Find(&dbApp)
|
||||
if res.Error != nil {
|
||||
return nil, res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return nil, fmt.Errorf("no app found for %s", name)
|
||||
}
|
||||
return &dbApp, nil
|
||||
}
|
||||
|
||||
func GetAppsList(e *env.Env, c echo.Context) error {
|
||||
appsList, err := e.Dokku.ListApps()
|
||||
|
||||
apps := make([]dto.GetAppsListItem, 0)
|
||||
for _, name := range appsList {
|
||||
if name != FilteredApp {
|
||||
apps = append(apps, dto.GetAppsListItem{
|
||||
Name: name,
|
||||
// TODO: get type
|
||||
Type: "",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, dokku.NoDeployedAppsError) {
|
||||
return fmt.Errorf("getting apps overview: %w", err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.GetAppsListResponse{
|
||||
Apps: apps,
|
||||
})
|
||||
}
|
||||
|
||||
func GetAppsProcessReport(e *env.Env, c echo.Context) error {
|
||||
allReports, err := e.Dokku.GetAllProcessReport()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get apps report: %w", err)
|
||||
}
|
||||
|
||||
apps := make([]dto.GetAppOverviewResponse, 0)
|
||||
for name, psReport := range allReports {
|
||||
if name == FilteredApp {
|
||||
continue
|
||||
}
|
||||
|
||||
app, lookupErr := lookupDBAppByName(e, name)
|
||||
if lookupErr != nil {
|
||||
return fmt.Errorf("failed to lookup app %s: %w", name, lookupErr)
|
||||
}
|
||||
|
||||
apps = append(apps, dto.GetAppOverviewResponse{
|
||||
Name: name,
|
||||
IsSetup: app.IsSetup,
|
||||
SetupMethod: app.SetupMethod,
|
||||
IsDeployed: psReport.Deployed,
|
||||
IsRunning: psReport.Running,
|
||||
NumProcesses: psReport.Processes,
|
||||
CanScale: psReport.CanScale,
|
||||
Restore: psReport.Restore,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.GetAllAppsOverviewResponse{
|
||||
Apps: apps,
|
||||
})
|
||||
}
|
||||
|
||||
func GetAppOverview(e *env.Env, c echo.Context) error {
|
||||
var req dto.GetAppOverviewRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
app, lookupErr := lookupDBAppByName(e, req.Name)
|
||||
if lookupErr != nil {
|
||||
return fmt.Errorf("failed to lookup app %s: %w", req.Name, lookupErr)
|
||||
}
|
||||
|
||||
psReport, psErr := e.Dokku.GetAppProcessReport(req.Name)
|
||||
if psErr != nil {
|
||||
return fmt.Errorf("getting apps process report: %w", psErr)
|
||||
}
|
||||
|
||||
gitReport, gitErr := e.Dokku.GitGetAppReport(req.Name)
|
||||
if gitErr != nil {
|
||||
return fmt.Errorf("getting app git report: %w", gitErr)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.GetAppOverviewResponse{
|
||||
IsSetup: app.IsSetup,
|
||||
SetupMethod: app.SetupMethod,
|
||||
IsDeployed: psReport.Deployed,
|
||||
GitDeployBranch: gitReport.DeployBranch,
|
||||
GitLastUpdated: gitReport.LastUpdatedAt,
|
||||
IsRunning: psReport.Running,
|
||||
})
|
||||
}
|
||||
|
||||
func GetAppInfo(e *env.Env, c echo.Context) error {
|
||||
var req dto.GetAppInfoRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
info, err := e.Dokku.GetAppReport(req.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting app info: %w", err)
|
||||
}
|
||||
|
||||
res := &dto.GetAppInfoResponse{
|
||||
Info: dto.AppInfo{
|
||||
Name: req.Name,
|
||||
Directory: info.Directory,
|
||||
DeploySource: info.DeploySource,
|
||||
DeploySourceMetadata: info.DeploySourceMetadata,
|
||||
CreatedAt: time.UnixMilli(info.CreatedAtTimestamp),
|
||||
IsLocked: info.IsLocked,
|
||||
},
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
func CreateApp(e *env.Env, c echo.Context) error {
|
||||
var req dto.ManageAppRequest
|
||||
if reqErr := dto.BindRequest(c, &req); reqErr != nil {
|
||||
log.Debug().
|
||||
Err(reqErr.ToHTTP()).
|
||||
Str("appName", req.Name).
|
||||
Msg("bind err")
|
||||
return reqErr.ToHTTP()
|
||||
}
|
||||
|
||||
_, lookupErr := lookupDBAppByName(e, req.Name)
|
||||
if lookupErr == nil {
|
||||
return echo.ErrBadRequest
|
||||
}
|
||||
|
||||
if createErr := e.Dokku.CreateApp(req.Name); createErr != nil {
|
||||
return fmt.Errorf("creating app: %w", createErr)
|
||||
}
|
||||
|
||||
if dbErr := e.DB.Create(&models.App{Name: req.Name}).Error; dbErr != nil {
|
||||
log.Error().Err(dbErr).Str("name", req.Name).Msg("failed to create db app")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func DestroyApp(e *env.Env, c echo.Context) error {
|
||||
var req dto.DestroyAppRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
dbApp, dbErr := lookupDBAppByName(e, req.Name)
|
||||
if dbErr != nil {
|
||||
log.Error().Err(dbErr).Str("name", req.Name).Msg("failed to lookup app")
|
||||
return echo.ErrNotFound
|
||||
}
|
||||
|
||||
if err := e.Dokku.DestroyApp(req.Name); err != nil {
|
||||
return fmt.Errorf("destroying app: %w", err)
|
||||
}
|
||||
|
||||
// TODO: hard delete app
|
||||
if err := e.DB.Delete(&dbApp).Error; err != nil {
|
||||
log.Error().Err(err).Str("name", req.Name).Msg("failed to delete app")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func RenameApp(e *env.Env, c echo.Context) error {
|
||||
var req dto.RenameAppRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
dbApp, dbErr := lookupDBAppByName(e, req.CurrentName)
|
||||
if dbErr != nil {
|
||||
log.Error().Err(dbErr).Str("name", req.CurrentName).
|
||||
Msg("failed to lookup app")
|
||||
return echo.ErrNotFound
|
||||
}
|
||||
|
||||
if _, newDbErr := lookupDBAppByName(e, req.NewName); newDbErr == nil {
|
||||
return echo.ErrBadRequest
|
||||
}
|
||||
|
||||
dbApp.Name = req.NewName
|
||||
if saveErr := e.DB.Save(&dbApp).Error; saveErr != nil {
|
||||
log.Error().Err(saveErr).
|
||||
Str("name", req.NewName).
|
||||
Msg("failed to save db app")
|
||||
}
|
||||
|
||||
options := &dokku.AppManagementOptions{SkipDeploy: true}
|
||||
if renameErr := e.Dokku.RenameApp(req.CurrentName, req.NewName, options); renameErr != nil {
|
||||
return fmt.Errorf("renaming app: %w", renameErr)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func StartApp(e *env.Env, c echo.Context) error {
|
||||
var req dto.ManageAppRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
cmd := func() (*dokku.CommandOutputStream, error) {
|
||||
return e.Dokku.StartApp(req.Name, nil)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.CommandExecutionResponse{
|
||||
ExecutionID: commands.RequestExecution(cmd, nil),
|
||||
})
|
||||
}
|
||||
|
||||
func StopApp(e *env.Env, c echo.Context) error {
|
||||
var req dto.ManageAppRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
cmd := func() (*dokku.CommandOutputStream, error) {
|
||||
return e.Dokku.StopApp(req.Name, nil)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.CommandExecutionResponse{
|
||||
ExecutionID: commands.RequestExecution(cmd, nil),
|
||||
})
|
||||
}
|
||||
|
||||
func RestartApp(e *env.Env, c echo.Context) error {
|
||||
var req dto.ManageAppRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
cmd := func() (*dokku.CommandOutputStream, error) {
|
||||
return e.Dokku.RestartApp(req.Name, nil)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.CommandExecutionResponse{
|
||||
ExecutionID: commands.RequestExecution(cmd, nil),
|
||||
})
|
||||
}
|
||||
|
||||
func RebuildApp(e *env.Env, c echo.Context) error {
|
||||
var req dto.ManageAppRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
dbApp, appErr := lookupDBAppByName(e, req.Name)
|
||||
if appErr != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "couldnt get app")
|
||||
}
|
||||
if !dbApp.IsSetup {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "not setup")
|
||||
}
|
||||
|
||||
var cfg models.AppSetupConfig
|
||||
cfgErr := e.DB.Where("app_id = ?", dbApp.ID).First(&cfg).Error
|
||||
if cfgErr != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "no setup config")
|
||||
}
|
||||
method := setupMethod(dbApp.SetupMethod)
|
||||
var cmd commands.AsyncDokkuCommand
|
||||
if method == methodGit {
|
||||
cmd = func() (*dokku.CommandOutputStream, error) {
|
||||
return e.Dokku.RebuildApp(req.Name, nil)
|
||||
}
|
||||
} else if method == methodSyncRepo {
|
||||
opts := &dokku.GitSyncOptions{
|
||||
Build: true,
|
||||
GitRef: cfg.RepoGitRef,
|
||||
}
|
||||
cmd = func() (*dokku.CommandOutputStream, error) {
|
||||
return e.Dokku.GitSyncAppRepo(req.Name, cfg.RepoURL, opts)
|
||||
}
|
||||
} else if method == methodDocker {
|
||||
image := cfg.Image
|
||||
cmd = func() (*dokku.CommandOutputStream, error) {
|
||||
return e.Dokku.GitCreateFromImage(req.Name, image, nil)
|
||||
}
|
||||
} else {
|
||||
log.Error().
|
||||
Str("method", dbApp.SetupMethod).
|
||||
Str("name", dbApp.Name).
|
||||
Msg("invalid app setup method")
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid setup method")
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.CommandExecutionResponse{
|
||||
ExecutionID: commands.RequestExecution(cmd, nil),
|
||||
})
|
||||
}
|
||||
|
||||
func GetAppDeployChecks(e *env.Env, c echo.Context) error {
|
||||
var req dto.GetAppDeployChecksRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
report, err := e.Dokku.GetAppDeployChecksReport(req.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting app deploy checks: %w", err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.GetAppDeployChecksResponse{
|
||||
AllDisabled: report.AllDisabled,
|
||||
AllSkipped: report.AllSkipped,
|
||||
DisabledProcesses: report.DisabledProcesses,
|
||||
SkippedProcesses: report.SkippedProcesses,
|
||||
})
|
||||
}
|
||||
|
||||
func SetAppDeployChecks(e *env.Env, c echo.Context) error {
|
||||
var req dto.SetAppDeployChecksRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
var err error
|
||||
switch req.State {
|
||||
case "enabled":
|
||||
err = e.Dokku.EnableAppDeployChecks(req.Name)
|
||||
case "disabled":
|
||||
err = e.Dokku.DisableAppDeployChecks(req.Name)
|
||||
case "skipped":
|
||||
err = e.Dokku.SetAppDeployChecksSkipped(req.Name)
|
||||
default:
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "unknown state")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting app deploy checks to %s: %w", req.State, err)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func GetAppLogs(e *env.Env, c echo.Context) error {
|
||||
var req dto.GetAppLogsRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
logs, err := e.Dokku.GetAppLogs(req.Name)
|
||||
if err != nil {
|
||||
if errors.Is(err, dokku.AppNotDeployedError) {
|
||||
return c.JSON(http.StatusOK, dto.GetAppLogsResponse{})
|
||||
}
|
||||
return fmt.Errorf("getting app logs: %w", err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.GetAppLogsResponse{
|
||||
Logs: strings.Split(logs, "\n"),
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
func AppExecInProcess(e *env.Env, c echo.Context) error {
|
||||
var req dto.AppExecInProcessRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
log.Debug().Str("err", err.String()).Interface("req", req).Msg("bind")
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf(`enter %s %s %s`, req.AppName, req.ProcessName, req.Command)
|
||||
|
||||
output, execErr := e.Dokku.Exec(cmd)
|
||||
|
||||
res := dto.AppExecInProcessResponse{
|
||||
Output: output,
|
||||
}
|
||||
if execErr != nil {
|
||||
res.Error = execErr.Error()
|
||||
|
||||
var sshExitErr *dokku.ExitCodeError
|
||||
if errors.As(execErr, &sshExitErr) {
|
||||
res.Error = sshExitErr.Output()
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, res)
|
||||
}
|
||||
*/
|
||||
84
internal/server/api/apps/networks.go
Normal file
84
internal/server/api/apps/networks.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/texm/dokku-go"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func GetAppNetworksReport(e *env.Env, c echo.Context) error {
|
||||
var req dto.GetAppNetworksReportRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
report, err := e.Dokku.GetAppNetworkReport(req.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting app network report: %w", err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.GetAppNetworksReportResponse{
|
||||
AttachInitial: report.ComputedInitialNetwork,
|
||||
AttachPostCreate: report.ComputedAttachPostCreate,
|
||||
AttachPostDeploy: report.ComputedAttachPostDeploy,
|
||||
BindAllInterfaces: report.ComputedBindAllInterfaces,
|
||||
TLD: report.ComputedTLD,
|
||||
WebListeners: report.WebListeners,
|
||||
})
|
||||
}
|
||||
|
||||
func SetAppNetworks(e *env.Env, c echo.Context) error {
|
||||
var req dto.SetAppNetworksRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
if req.Initial != nil {
|
||||
err := e.Dokku.SetAppNetworkProperty(req.Name,
|
||||
dokku.NetworkPropertyInitialNetwork, *req.Initial)
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting initial network: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if req.PostCreate != nil {
|
||||
err := e.Dokku.SetAppNetworkProperty(req.Name,
|
||||
dokku.NetworkPropertyAttachPostCreate, *req.PostCreate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting postcreate network: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if req.PostDeploy != nil {
|
||||
err := e.Dokku.SetAppNetworkProperty(req.Name,
|
||||
dokku.NetworkPropertyAttachPostDeploy, *req.PostDeploy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting postdeploy network: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if req.BindAllInterfaces != nil {
|
||||
bindAll := "false"
|
||||
if *req.BindAllInterfaces {
|
||||
bindAll = "true"
|
||||
}
|
||||
err := e.Dokku.SetAppNetworkProperty(req.Name,
|
||||
dokku.NetworkPropertyBindAllInterfaces, bindAll)
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting bind-all-interfaces: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if req.TLD != nil {
|
||||
err := e.Dokku.SetAppNetworkProperty(req.Name,
|
||||
dokku.NetworkPropertyTLD, *req.TLD)
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting TLD: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
190
internal/server/api/apps/processes.go
Normal file
190
internal/server/api/apps/processes.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/texm/dokku-go"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/server/commands"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func GetAppProcesses(e *env.Env, c echo.Context) error {
|
||||
var req dto.GetAppProcessesRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
scales, err := e.Dokku.GetAppProcessScale(req.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting app process scale: %w", err)
|
||||
}
|
||||
|
||||
processes := make([]string, len(scales))
|
||||
i := 0
|
||||
for processName := range scales {
|
||||
processes[i] = processName
|
||||
i++
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.GetAppProcessesResponse{
|
||||
Processes: processes,
|
||||
})
|
||||
}
|
||||
|
||||
func GetAppProcessReport(e *env.Env, c echo.Context) error {
|
||||
var req dto.GetAppProcessReportRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
resourceReport, err := e.Dokku.GetAppResourceReport(req.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting app resource report: %w", err)
|
||||
}
|
||||
|
||||
processScale, err := e.Dokku.GetAppProcessScale(req.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting app process scale: %w", err)
|
||||
}
|
||||
|
||||
processMap := map[string]dto.AppProcessInfo{}
|
||||
for processName, scale := range processScale {
|
||||
appResources := dto.AppProcessInfo{
|
||||
Scale: scale,
|
||||
}
|
||||
if psSettings, ok := resourceReport.Processes[processName]; ok {
|
||||
appResources.Resources = psSettings
|
||||
}
|
||||
processMap[processName] = appResources
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.GetAppProcessReportResponse{
|
||||
ResourceDefaults: resourceReport.Defaults,
|
||||
Processes: processMap,
|
||||
})
|
||||
}
|
||||
|
||||
func SetAppProcessResources(e *env.Env, c echo.Context) error {
|
||||
var req dto.SetAppProcessResourcesRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
limits := req.ResourceLimits
|
||||
reservations := req.ResourceReservations
|
||||
|
||||
if limits.CPU != nil {
|
||||
err := e.Dokku.SetAppProcessResourceLimit(req.Name, req.Process,
|
||||
dokku.ResourceCPU, *limits.CPU)
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting app cpu limit: %w", err)
|
||||
}
|
||||
} else {
|
||||
err := e.Dokku.ClearAppProcessResourceLimit(req.Name, req.Process, dokku.ResourceCPU)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clearing app cpu limit: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if limits.Memory != nil && limits.MemoryUnit != nil {
|
||||
resSpec := dokku.ResourceSpec{
|
||||
Name: "memory",
|
||||
Suffix: *limits.MemoryUnit,
|
||||
}
|
||||
err := e.Dokku.SetAppProcessResourceLimit(req.Name, req.Process, resSpec, *limits.Memory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting app mem limit: %w", err)
|
||||
}
|
||||
} else {
|
||||
err := e.Dokku.ClearAppProcessResourceLimit(req.Name, req.Process, dokku.ResourceMemoryBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clearing app mem limit: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if reservations.Memory != nil && reservations.MemoryUnit != nil {
|
||||
resSpec := dokku.ResourceSpec{
|
||||
Name: "memory",
|
||||
Suffix: *reservations.MemoryUnit,
|
||||
}
|
||||
err := e.Dokku.SetAppProcessResourceReservation(req.Name, req.Process, resSpec,
|
||||
*reservations.Memory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting app mem reservation: %w", err)
|
||||
}
|
||||
} else {
|
||||
err := e.Dokku.ClearAppProcessResourceReservation(req.Name, req.Process,
|
||||
dokku.ResourceMemoryBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clearing app mem reservation: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func GetAppProcessScale(e *env.Env, c echo.Context) error {
|
||||
var req dto.GetAppProcessScaleRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
scale, err := e.Dokku.GetAppProcessScale(req.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting app process scale: %w", err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.GetAppProcessScaleResponse{
|
||||
ProcessScale: scale,
|
||||
})
|
||||
}
|
||||
|
||||
func SetAppProcessDeployChecks(e *env.Env, c echo.Context) error {
|
||||
var req dto.SetAppProcessDeployChecksRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
processes := []string{req.Process}
|
||||
var err error
|
||||
switch req.State {
|
||||
case "enabled":
|
||||
err = e.Dokku.EnableAppProcessesDeployChecks(req.Name, processes)
|
||||
case "disabled":
|
||||
err = e.Dokku.DisableAppProcessesDeployChecks(req.Name, processes)
|
||||
case "skipped":
|
||||
err = e.Dokku.SetAppProcessesDeployChecksSkipped(req.Name, processes)
|
||||
default:
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "unknown state")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting app deploy checks to " + req.State)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func SetAppProcessScale(e *env.Env, c echo.Context) error {
|
||||
var req dto.SetAppProcessScaleRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
_, err := e.Auth.GetUserFromContext(c)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to retrieve user from context")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
cmd := func() (*dokku.CommandOutputStream, error) {
|
||||
return e.Dokku.SetAppProcessScale(req.Name, req.Process, req.Scale, req.SkipDeploy)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.CommandExecutionResponse{
|
||||
ExecutionID: commands.RequestExecution(cmd, nil),
|
||||
})
|
||||
}
|
||||
70
internal/server/api/apps/routes.go
Normal file
70
internal/server/api/apps/routes.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
)
|
||||
|
||||
func RegisterRoutes(e *env.Env, g *echo.Group) {
|
||||
g.GET("/list", e.H(GetAppsList))
|
||||
g.GET("/report", e.H(GetAppsProcessReport))
|
||||
g.GET("/overview", e.H(GetAppOverview))
|
||||
|
||||
g.POST("/create", e.H(CreateApp))
|
||||
g.POST("/start", e.H(StartApp))
|
||||
g.POST("/stop", e.H(StopApp))
|
||||
g.POST("/restart", e.H(RestartApp))
|
||||
g.POST("/rebuild", e.H(RebuildApp))
|
||||
|
||||
setupGroup := g.Group("/setup")
|
||||
setupGroup.GET("/status", e.H(GetAppSetupStatus))
|
||||
setupGroup.GET("/config", e.H(GetAppSetupConfig))
|
||||
setupGroup.POST("/new-repo", e.H(SetupAppNewRepo))
|
||||
setupGroup.POST("/sync-repo", e.H(SetupAppSyncRepo))
|
||||
setupGroup.POST("/pull-image", e.H(SetupAppPullImage))
|
||||
setupGroup.POST("/upload-archive", e.H(SetupAppUploadArchive))
|
||||
|
||||
g.POST("/destroy", e.H(DestroyApp))
|
||||
g.GET("/info", e.H(GetAppInfo))
|
||||
g.POST("/rename", e.H(RenameApp))
|
||||
g.GET("/services", e.H(GetAppServices))
|
||||
|
||||
g.GET("/deploy-checks", e.H(GetAppDeployChecks))
|
||||
g.POST("/deploy-checks", e.H(SetAppDeployChecks))
|
||||
|
||||
process := g.Group("/process")
|
||||
process.GET("/list", e.H(GetAppProcesses))
|
||||
process.GET("/report", e.H(GetAppProcessReport))
|
||||
process.POST("/deploy-checks", e.H(SetAppProcessDeployChecks))
|
||||
process.POST("/resources", e.H(SetAppProcessResources))
|
||||
process.GET("/scale", e.H(GetAppProcessScale))
|
||||
process.POST("/scale", e.H(SetAppProcessScale))
|
||||
|
||||
g.GET("/letsencrypt", e.H(GetAppLetsEncryptEnabled))
|
||||
g.POST("/letsencrypt", e.H(SetAppLetsEncryptEnabled))
|
||||
|
||||
g.GET("/domains", e.H(GetAppDomainsReport))
|
||||
g.POST("/domains/state", e.H(SetAppDomainsEnabled))
|
||||
|
||||
g.POST("/domain", e.H(AddAppDomain))
|
||||
g.DELETE("/domain", e.H(RemoveAppDomain))
|
||||
|
||||
g.GET("/networks", e.H(GetAppNetworksReport))
|
||||
g.POST("/networks", e.H(SetAppNetworks))
|
||||
|
||||
g.GET("/logs", e.H(GetAppLogs))
|
||||
|
||||
g.GET("/config", e.H(GetAppConfig))
|
||||
g.POST("/config", e.H(SetAppConfig))
|
||||
|
||||
g.GET("/storage", e.H(GetAppStorage))
|
||||
g.POST("/storage/mount", e.H(MountAppStorage))
|
||||
g.POST("/storage/unmount", e.H(UnmountAppStorage))
|
||||
|
||||
g.GET("/builder", e.H(GetAppBuilder))
|
||||
g.POST("/builder", e.H(SetAppBuilder))
|
||||
|
||||
g.GET("/build-directory", e.H(GetAppBuildDirectory))
|
||||
g.POST("/build-directory", e.H(SetAppBuildDirectory))
|
||||
g.DELETE("/build-directory", e.H(ClearAppBuildDirectory))
|
||||
}
|
||||
60
internal/server/api/apps/services.go
Normal file
60
internal/server/api/apps/services.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
dokkuErrPrefix = "! "
|
||||
serviceTypes = []string{"redis", "postgres", "mysql", "mongo"}
|
||||
)
|
||||
|
||||
func splitDokkuListOutput(output string) ([]string, error) {
|
||||
if strings.HasPrefix(output, dokkuErrPrefix) {
|
||||
return nil, nil
|
||||
}
|
||||
if output == "" {
|
||||
return []string{}, nil
|
||||
}
|
||||
return strings.Split(output, "\n"), nil
|
||||
}
|
||||
|
||||
func getAppServiceLinks(e *env.Env, appName string, serviceType string) ([]string, error) {
|
||||
linksCmd := fmt.Sprintf("%s:app-links %s --quiet", serviceType, appName)
|
||||
out, err := e.Dokku.Exec(linksCmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return splitDokkuListOutput(out)
|
||||
}
|
||||
|
||||
func GetAppServices(e *env.Env, c echo.Context) error {
|
||||
var req dto.GetAppServicesRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
serviceList := []dto.ServiceInfo{}
|
||||
|
||||
for _, serviceType := range serviceTypes {
|
||||
links, err := getAppServiceLinks(e, req.Name, serviceType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting links for " + serviceType)
|
||||
}
|
||||
for _, name := range links {
|
||||
serviceList = append(serviceList, dto.ServiceInfo{
|
||||
Name: name,
|
||||
Type: serviceType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.ListServicesResponse{
|
||||
Services: serviceList,
|
||||
})
|
||||
}
|
||||
229
internal/server/api/apps/setup.go
Normal file
229
internal/server/api/apps/setup.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/texm/dokku-go"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/models"
|
||||
"gitlab.com/texm/shokku/internal/server/auth"
|
||||
"gitlab.com/texm/shokku/internal/server/commands"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
"gorm.io/gorm/clause"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type setupMethod string
|
||||
|
||||
const (
|
||||
methodGit = setupMethod("git")
|
||||
methodSyncRepo = setupMethod("sync-repo")
|
||||
methodDocker = setupMethod("docker")
|
||||
methodArchive = setupMethod("archive")
|
||||
)
|
||||
|
||||
var (
|
||||
updateOnConflict = clause.OnConflict{
|
||||
DoUpdates: clause.AssignmentColumns([]string{"updated_at"}),
|
||||
}
|
||||
)
|
||||
|
||||
func saveAppSetupMethod(e *env.Env, app *models.App, method setupMethod, cfg *models.AppSetupConfig) error {
|
||||
app.IsSetup = true
|
||||
app.SetupMethod = string(method)
|
||||
if appErr := e.DB.Save(app).Error; appErr != nil {
|
||||
log.Error().Err(appErr).
|
||||
Str("app", app.Name).
|
||||
Str("method", app.SetupMethod).
|
||||
Msg("failed to save app table")
|
||||
return fmt.Errorf("failed to save app table: %w", appErr)
|
||||
}
|
||||
|
||||
cfg.AppID = app.ID
|
||||
|
||||
updateErr := e.DB.Clauses(updateOnConflict).Create(&cfg).Error
|
||||
if updateErr != nil {
|
||||
log.Error().Err(updateErr).
|
||||
Str("app", app.Name).
|
||||
Interface("cfg", cfg).
|
||||
Msg("failed to update app setup config")
|
||||
return fmt.Errorf("failed to save app setup config: %w", updateErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetAppSetupStatus(e *env.Env, c echo.Context) error {
|
||||
var req dto.GetAppSetupStatusRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
dbApp, appErr := lookupDBAppByName(e, req.Name)
|
||||
if appErr != nil {
|
||||
return echo.ErrNotFound
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.GetAppSetupStatusResponse{
|
||||
IsSetup: dbApp.IsSetup,
|
||||
Method: dbApp.SetupMethod,
|
||||
})
|
||||
}
|
||||
|
||||
func GetAppSetupConfig(e *env.Env, c echo.Context) error {
|
||||
var req dto.GetAppSetupConfigRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
dbApp, appErr := lookupDBAppByName(e, req.Name)
|
||||
if appErr != nil {
|
||||
return echo.ErrNotFound
|
||||
}
|
||||
|
||||
var cfg models.AppSetupConfig
|
||||
e.DB.Where("app_id = ?", dbApp.ID).FirstOrInit(&cfg)
|
||||
|
||||
return c.JSON(http.StatusOK, dto.GetAppSetupConfigResponse{
|
||||
IsSetup: dbApp.IsSetup,
|
||||
Method: dbApp.SetupMethod,
|
||||
DeploymentBranch: cfg.DeployBranch,
|
||||
RepoURL: cfg.RepoURL,
|
||||
RepoGitRef: cfg.RepoGitRef,
|
||||
Image: cfg.Image,
|
||||
})
|
||||
}
|
||||
|
||||
func SetupAppNewRepo(e *env.Env, c echo.Context) error {
|
||||
var req dto.SetupAppNewRepoRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
dbApp, appErr := lookupDBAppByName(e, req.Name)
|
||||
if appErr != nil {
|
||||
return echo.ErrNotFound
|
||||
}
|
||||
|
||||
if initErr := e.Dokku.GitInitializeApp(req.Name); initErr != nil {
|
||||
return fmt.Errorf("initialising git repo: %w", initErr)
|
||||
}
|
||||
|
||||
branch := req.DeploymentBranch
|
||||
if branch == "" {
|
||||
branch = "master"
|
||||
}
|
||||
|
||||
if branch != "master" {
|
||||
branchErr := e.Dokku.GitSetAppProperty(req.Name, dokku.GitPropertyDeployBranch, req.DeploymentBranch)
|
||||
if branchErr != nil {
|
||||
return fmt.Errorf("setting git deploy branch: %w", branchErr)
|
||||
}
|
||||
}
|
||||
|
||||
cfg := &models.AppSetupConfig{
|
||||
DeployBranch: branch,
|
||||
}
|
||||
if saveErr := saveAppSetupMethod(e, dbApp, methodGit, cfg); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func SetupAppSyncRepo(e *env.Env, c echo.Context) error {
|
||||
var req dto.SetupAppSyncRepoRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
dbApp, appErr := lookupDBAppByName(e, req.Name)
|
||||
if appErr != nil {
|
||||
return echo.ErrNotFound
|
||||
}
|
||||
|
||||
parsedURL, urlErr := url.Parse(req.RepositoryURL)
|
||||
if urlErr != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid repo url")
|
||||
}
|
||||
|
||||
if e.Auth.GetMethod() == auth.MethodGithub {
|
||||
if parsedURL.Hostname() != "github.com" {
|
||||
// return echo.ErrBadRequest
|
||||
}
|
||||
}
|
||||
|
||||
syncOpts := &dokku.GitSyncOptions{
|
||||
Build: true,
|
||||
GitRef: req.GitRef,
|
||||
}
|
||||
execFn := func() (*dokku.CommandOutputStream, error) {
|
||||
return e.Dokku.GitSyncAppRepo(req.Name, req.RepositoryURL, syncOpts)
|
||||
}
|
||||
|
||||
cfg := &models.AppSetupConfig{
|
||||
AppID: dbApp.ID,
|
||||
RepoURL: req.RepositoryURL,
|
||||
RepoGitRef: req.GitRef,
|
||||
}
|
||||
cb := func() error {
|
||||
return saveAppSetupMethod(e, dbApp, methodSyncRepo, cfg)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.CommandExecutionResponse{
|
||||
ExecutionID: commands.RequestExecution(execFn, cb),
|
||||
})
|
||||
}
|
||||
|
||||
func SetupAppPullImage(e *env.Env, c echo.Context) error {
|
||||
var req dto.SetupAppPullImageRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
dbApp, appErr := lookupDBAppByName(e, req.Name)
|
||||
if appErr != nil {
|
||||
return echo.ErrNotFound
|
||||
}
|
||||
|
||||
execFn := func() (*dokku.CommandOutputStream, error) {
|
||||
return e.Dokku.GitCreateFromImage(req.Name, req.Image, nil)
|
||||
}
|
||||
|
||||
cfg := &models.AppSetupConfig{
|
||||
AppID: dbApp.ID,
|
||||
Image: req.Image,
|
||||
}
|
||||
cb := func() error {
|
||||
return saveAppSetupMethod(e, dbApp, methodDocker, cfg)
|
||||
}
|
||||
return c.JSON(http.StatusOK, dto.CommandExecutionResponse{
|
||||
ExecutionID: commands.RequestExecution(execFn, cb),
|
||||
})
|
||||
}
|
||||
|
||||
func SetupAppUploadArchive(e *env.Env, c echo.Context) error {
|
||||
var req dto.SetupAppUploadArchiveRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
// disable for now
|
||||
if true {
|
||||
return echo.ErrForbidden
|
||||
}
|
||||
|
||||
file, err := c.FormFile("archive")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("invalid form file")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("app", req.Name).
|
||||
Msgf("got file: %+v", file.Header)
|
||||
|
||||
return c.NoContent(http.StatusNotImplemented)
|
||||
}
|
||||
107
internal/server/api/apps/storage.go
Normal file
107
internal/server/api/apps/storage.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/texm/dokku-go"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func isMountInSlice(mount dokku.StorageBindMount, mounts []dokku.StorageBindMount) bool {
|
||||
for _, m := range mounts {
|
||||
if m == mount {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func GetAppStorage(e *env.Env, c echo.Context) error {
|
||||
var req dto.GetAppStorageRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
report, err := e.Dokku.GetAppStorageReport(req.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting app storage report: %w", err)
|
||||
}
|
||||
|
||||
var allMounts []dokku.StorageBindMount
|
||||
allMounts = append(allMounts, report.RunMounts...)
|
||||
allMounts = append(allMounts, report.DeployMounts...)
|
||||
allMounts = append(allMounts, report.BuildMounts...)
|
||||
|
||||
seenMap := map[string]bool{}
|
||||
mounts := []dto.StorageMount{}
|
||||
for _, dokkuMount := range allMounts {
|
||||
if _, seen := seenMap[dokkuMount.String()]; seen {
|
||||
continue
|
||||
}
|
||||
seenMap[dokkuMount.String()] = true
|
||||
mounts = append(mounts, dto.StorageMount{
|
||||
HostDir: dokkuMount.HostDir,
|
||||
ContainerDir: dokkuMount.ContainerDir,
|
||||
IsBuildMount: isMountInSlice(dokkuMount, report.BuildMounts),
|
||||
IsRunMount: isMountInSlice(dokkuMount, report.RunMounts),
|
||||
IsDeployMount: isMountInSlice(dokkuMount, report.DeployMounts),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.GetAppStorageResponse{
|
||||
Mounts: mounts,
|
||||
})
|
||||
}
|
||||
|
||||
func MountAppStorage(e *env.Env, c echo.Context) error {
|
||||
var req dto.AlterAppStorageRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
if req.StorageType == "New Storage" {
|
||||
// TODO: actually chown properly
|
||||
err := e.Dokku.EnsureStorageDirectory(req.Name, dokku.StorageChownOptionHerokuish)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ensuring app storage dir: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
mount := dokku.StorageBindMount{
|
||||
HostDir: req.HostDir,
|
||||
ContainerDir: req.ContainerDir,
|
||||
}
|
||||
if err := e.Dokku.MountAppStorage(req.Name, mount); err != nil {
|
||||
return fmt.Errorf("mounting app storage dir: %w", err)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func UnmountAppStorage(e *env.Env, c echo.Context) error {
|
||||
var req dto.AlterAppStorageRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
mount := dokku.StorageBindMount{
|
||||
HostDir: req.HostDir,
|
||||
ContainerDir: req.ContainerDir,
|
||||
}
|
||||
if err := e.Dokku.UnmountAppStorage(req.Name, mount); err != nil {
|
||||
return fmt.Errorf("unmounting app storage dir: %w", err)
|
||||
}
|
||||
|
||||
if req.RestartApp {
|
||||
go func() {
|
||||
if _, err := e.Dokku.RestartApp(req.Name, nil); err != nil {
|
||||
log.Error().Err(err).Msg("error while restarting app")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
47
internal/server/api/auth/auth.go
Normal file
47
internal/server/api/auth/auth.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/server/auth"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func HandleLogout(e *env.Env, c echo.Context) error {
|
||||
e.Auth.ClearTokenCookies(c)
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func HandleRefreshAuth(e *env.Env, c echo.Context) error {
|
||||
user, err := e.Auth.GetUserFromContext(c)
|
||||
if err != nil {
|
||||
log.Error().Msg("failed to parse user from context")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
claims := auth.UserClaims{
|
||||
Name: user.Name,
|
||||
}
|
||||
token, err := e.Auth.NewToken(claims)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to create jwt")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
e.Auth.SetTokenCookies(c, token)
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func HandleGetDetails(e *env.Env, c echo.Context) error {
|
||||
user, err := e.Auth.GetUserFromContext(c)
|
||||
if err != nil {
|
||||
log.Error().Msg("failed to parse user from context")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, echo.Map{
|
||||
"username": user.Name,
|
||||
})
|
||||
}
|
||||
76
internal/server/api/auth/github.go
Normal file
76
internal/server/api/auth/github.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/models"
|
||||
"gitlab.com/texm/shokku/internal/server/auth"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
"gitlab.com/texm/shokku/internal/server/github"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func GetGithubAuthInfo(e *env.Env, c echo.Context) error {
|
||||
var ghApp models.GithubApp
|
||||
if err := e.DB.First(&ghApp).Error; err != nil {
|
||||
log.Error().Err(err).Msg("no github app in db")
|
||||
return echo.ErrBadRequest
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.GetGithubAuthInfoResponse{
|
||||
ClientID: ghApp.ClientId,
|
||||
})
|
||||
}
|
||||
|
||||
func CompleteGithubAuth(e *env.Env, c echo.Context) error {
|
||||
var req dto.GithubAuthRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return echo.ErrBadRequest
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
params := github.CodeExchangeParams{
|
||||
Code: req.Code,
|
||||
Scopes: []string{},
|
||||
RedirectURL: req.RedirectURL,
|
||||
}
|
||||
client, err := github.ExchangeCode(ctx, e, params)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to exchange code for client")
|
||||
return echo.ErrBadRequest
|
||||
}
|
||||
|
||||
user, err := client.GetUser(ctx)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to get user")
|
||||
return echo.ErrBadRequest
|
||||
}
|
||||
|
||||
var count int64
|
||||
dbUser := models.User{Name: user.GetLogin()}
|
||||
r := e.DB.Model(&dbUser).Where(&dbUser).Count(&count)
|
||||
if r.Error != nil {
|
||||
log.Error().
|
||||
Err(r.Error).
|
||||
Str("name", user.GetLogin()).
|
||||
Msg("user lookup error")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
if count == 0 {
|
||||
return echo.ErrForbidden
|
||||
}
|
||||
|
||||
claims := auth.UserClaims{
|
||||
Name: user.GetLogin(),
|
||||
}
|
||||
token, err := e.Auth.NewToken(claims)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to create jwt")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
e.Auth.SetTokenCookies(c, token)
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
58
internal/server/api/auth/password.go
Normal file
58
internal/server/api/auth/password.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
auth "gitlab.com/texm/shokku/internal/server/auth"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"gitlab.com/texm/shokku/internal/models"
|
||||
)
|
||||
|
||||
func HandlePasswordLogin(e *env.Env, c echo.Context) error {
|
||||
var req dto.PasswordLoginRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return echo.ErrBadRequest
|
||||
}
|
||||
|
||||
var dbUser models.User
|
||||
res := e.DB.Where("name = ?", req.Username).Take(&dbUser)
|
||||
if res.Error != nil {
|
||||
return echo.ErrForbidden
|
||||
}
|
||||
|
||||
pwAuth, ok := e.Auth.(*auth.PasswordAuthenticator)
|
||||
if !ok {
|
||||
log.Error().Msg("failed to cast authenticator to pw auth")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
if !pwAuth.VerifyHash([]byte(req.Password), dbUser.PasswordHash) {
|
||||
return echo.ErrForbidden
|
||||
}
|
||||
|
||||
if dbUser.TotpEnabled {
|
||||
if !totp.Validate(req.TotpCode, dbUser.TotpSecret) {
|
||||
return c.JSON(http.StatusOK, dto.PasswordLoginResponse{
|
||||
Success: true,
|
||||
NeedsTotp: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
claims := auth.UserClaims{
|
||||
Name: req.Username,
|
||||
}
|
||||
token, err := e.Auth.NewToken(claims)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to create jwt")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
e.Auth.SetTokenCookies(c, token)
|
||||
|
||||
return c.JSON(http.StatusOK, dto.PasswordLoginResponse{
|
||||
Success: true,
|
||||
})
|
||||
}
|
||||
16
internal/server/api/auth/routes.go
Normal file
16
internal/server/api/auth/routes.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
)
|
||||
|
||||
func RegisterRoutes(e *env.Env, g *echo.Group, authG *echo.Group) {
|
||||
g.POST("/login", e.H(HandlePasswordLogin))
|
||||
g.GET("/github", e.H(GetGithubAuthInfo))
|
||||
g.POST("/github/auth", e.H(CompleteGithubAuth))
|
||||
g.POST("/logout", e.H(HandleLogout))
|
||||
|
||||
authG.GET("/details", e.H(HandleGetDetails))
|
||||
authG.POST("/refresh", e.H(HandleRefreshAuth))
|
||||
}
|
||||
34
internal/server/api/command.go
Normal file
34
internal/server/api/command.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/labstack/echo/v4"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/server/commands"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func GetCommandExecutionStatus(e *env.Env, c echo.Context) error {
|
||||
var req dto.GetCommandExecutionStatusRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
status, err := commands.GetExecutionStatus(req.ExecutionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, commands.ErrNotPolled) {
|
||||
return c.JSON(http.StatusOK, dto.CommandExecutionStatusResponse{
|
||||
Started: false,
|
||||
})
|
||||
}
|
||||
return echo.ErrNotFound
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.CommandExecutionStatusResponse{
|
||||
CombinedOutput: status.CombinedOutput,
|
||||
Started: true,
|
||||
Finished: status.Finished,
|
||||
Success: status.StreamError == nil,
|
||||
})
|
||||
}
|
||||
58
internal/server/api/github.go
Normal file
58
internal/server/api/github.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/google/go-github/v48/github"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/models"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func ReceiveGithubWebhookEvent(e *env.Env, c echo.Context) error {
|
||||
var ghApp models.GithubApp
|
||||
if ghErr := e.DB.First(&ghApp).Error; ghErr != nil {
|
||||
log.Error().Err(ghErr).Msg("failed to retrieve github app info")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
req := c.Request()
|
||||
secret := []byte(ghApp.WebhookSecret)
|
||||
payload, validationErr := github.ValidatePayload(req, secret)
|
||||
if validationErr != nil {
|
||||
return echo.ErrBadRequest
|
||||
}
|
||||
event, parseErr := github.ParseWebHook(github.WebHookType(req), payload)
|
||||
if parseErr != nil {
|
||||
log.Error().Err(parseErr).Msg("failed to parse webhook")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
var err error
|
||||
switch event := event.(type) {
|
||||
case *github.MetaEvent:
|
||||
err = processMetaEvent(e, event)
|
||||
case *github.PushEvent:
|
||||
err = processPushEvent(e, event)
|
||||
default:
|
||||
log.Error().
|
||||
Interface("type", event).
|
||||
Msg("received unsupported webhook event")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func processMetaEvent(e *env.Env, event *github.MetaEvent) error {
|
||||
log.Debug().Interface("event", event).Msg("got meta event")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func processPushEvent(e *env.Env, event *github.PushEvent) error {
|
||||
return nil
|
||||
}
|
||||
26
internal/server/api/routes.go
Normal file
26
internal/server/api/routes.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/server/api/apps"
|
||||
"gitlab.com/texm/shokku/internal/server/api/auth"
|
||||
"gitlab.com/texm/shokku/internal/server/api/services"
|
||||
"gitlab.com/texm/shokku/internal/server/api/settings"
|
||||
"gitlab.com/texm/shokku/internal/server/api/setup"
|
||||
)
|
||||
|
||||
func RegisterRoutes(e *env.Env, authMiddleware []echo.MiddlewareFunc) {
|
||||
g := e.Router.Group("/api")
|
||||
|
||||
setup.RegisterRoutes(e, g.Group("/setup"))
|
||||
auth.RegisterRoutes(e, g.Group("/auth"), g.Group("/auth", authMiddleware...))
|
||||
|
||||
protectedApi := g.Group("", authMiddleware...)
|
||||
apps.RegisterRoutes(e, protectedApi.Group("/apps"))
|
||||
services.RegisterRoutes(e, protectedApi.Group("/services"))
|
||||
settings.RegisterRoutes(e, protectedApi.Group("/settings"))
|
||||
|
||||
protectedApi.GET("/exec/status", e.H(GetCommandExecutionStatus))
|
||||
g.POST("/github/events", e.H(ReceiveGithubWebhookEvent))
|
||||
}
|
||||
57
internal/server/api/routes_test.go
Normal file
57
internal/server/api/routes_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func createQueryContext(route string, params url.Values) (*env.Env, echo.Context) {
|
||||
e := env.NewTestingEnvironment()
|
||||
|
||||
uri := fmt.Sprintf("%s/?%s", route, params.Encode())
|
||||
req := httptest.NewRequest(http.MethodGet, uri, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
c := e.Router.NewContext(req, rec)
|
||||
|
||||
e.Router.GET("/ping", e.H(pingRoute))
|
||||
|
||||
return e, c
|
||||
}
|
||||
|
||||
type pingRequest struct {
|
||||
Foo string `query:"foo" validate:"alphanum"`
|
||||
}
|
||||
|
||||
func pingRoute(e *env.Env, c echo.Context) error {
|
||||
var req pingRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func TestPingRouteValidatesSuccess(t *testing.T) {
|
||||
q := make(url.Values)
|
||||
q.Set("foo", "bar")
|
||||
e, c := createQueryContext("/ping", q)
|
||||
|
||||
err := pingRoute(e, c)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestPingRouteValidatesFail(t *testing.T) {
|
||||
q := make(url.Values)
|
||||
q.Set("foo", "bar!!")
|
||||
e, c := createQueryContext("/ping", q)
|
||||
|
||||
err := pingRoute(e, c)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
209
internal/server/api/services/backups.go
Normal file
209
internal/server/api/services/backups.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/texm/dokku-go"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/server/commands"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func GetServiceBackupReport(e *env.Env, c echo.Context) error {
|
||||
var req dto.GetServiceBackupReportRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
dbSvc, err := lookupDBServiceByName(e, req.Name)
|
||||
if err != nil {
|
||||
return echo.ErrNotFound
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("%s:backup-schedule-cat %s", dbSvc.Type, req.Name)
|
||||
backupSchedule, err := e.Dokku.Exec(cmd)
|
||||
if err != nil {
|
||||
backupSchedule = ""
|
||||
}
|
||||
|
||||
report := dto.ServiceBackupReport{
|
||||
AuthSet: dbSvc.BackupAuthSet,
|
||||
EncryptionSet: dbSvc.BackupEncryptionSet,
|
||||
Bucket: dbSvc.BackupBucket,
|
||||
Schedule: backupSchedule,
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.GetServiceBackupReportResponse{
|
||||
Report: report,
|
||||
})
|
||||
}
|
||||
|
||||
func SetServiceBackupAuth(e *env.Env, c echo.Context) error {
|
||||
var req dto.SetServiceBackupsAuthRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
dbSvc, err := lookupDBServiceByName(e, req.Name)
|
||||
if err != nil {
|
||||
return echo.ErrNotFound
|
||||
}
|
||||
|
||||
cfg := req.Config
|
||||
args := fmt.Sprintf("%s %s %s %s %s", cfg.AccessKeyId, cfg.SecretKey,
|
||||
cfg.Region, cfg.SignatureVersion, cfg.EndpointUrl)
|
||||
cmd := fmt.Sprintf("%s:backup-auth %s %s", dbSvc.Type, req.Name, args)
|
||||
if _, execErr := e.Dokku.Exec(cmd); execErr != nil {
|
||||
return fmt.Errorf("setting backup auth: %w", execErr)
|
||||
}
|
||||
|
||||
dbSvc.BackupAuthSet = true
|
||||
if err := e.DB.Save(&dbSvc).Error; err != nil {
|
||||
log.Error().Err(err).Msg("error updating service backup auth")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func SetServiceBackupBucket(e *env.Env, c echo.Context) error {
|
||||
var req dto.SetServiceBackupsBucketRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
dbSvc, err := lookupDBServiceByName(e, req.Name)
|
||||
if err != nil {
|
||||
return echo.ErrNotFound
|
||||
}
|
||||
|
||||
dbSvc.BackupBucket = req.Bucket
|
||||
if dbErr := e.DB.Save(&dbSvc).Error; dbErr != nil {
|
||||
log.Error().Err(dbErr).Msg("error updating service backup bucket")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func RunServiceBackup(e *env.Env, c echo.Context) error {
|
||||
var req dto.RunServiceBackupRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
dbSvc, err := lookupDBServiceByName(e, req.Name)
|
||||
if err != nil {
|
||||
return echo.ErrNotFound
|
||||
}
|
||||
if dbSvc.BackupBucket == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "backup bucket not set")
|
||||
} else if !dbSvc.BackupAuthSet {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "backup auth not set")
|
||||
}
|
||||
|
||||
dokkuCmd := fmt.Sprintf("%s:backup %s %s", dbSvc.Type, dbSvc.Name, dbSvc.BackupBucket)
|
||||
|
||||
cmd := func() (*dokku.CommandOutputStream, error) {
|
||||
return e.Dokku.ExecStreaming(dokkuCmd)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.CommandExecutionResponse{
|
||||
ExecutionID: commands.RequestExecution(cmd, nil),
|
||||
})
|
||||
}
|
||||
|
||||
func SetServiceBackupSchedule(e *env.Env, c echo.Context) error {
|
||||
var req dto.SetServiceBackupsScheduleRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
dbSvc, err := lookupDBServiceByName(e, req.Name)
|
||||
if err != nil {
|
||||
return echo.ErrNotFound
|
||||
}
|
||||
if dbSvc.BackupBucket == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest,
|
||||
"service backup bucket not set")
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf(`%s:backup-schedule %s "%s" %s`, dbSvc.Type,
|
||||
req.Name, req.Schedule, dbSvc.BackupBucket)
|
||||
if out, err := e.Dokku.Exec(cmd); err != nil {
|
||||
log.Debug().Str("output", out).Msg("backup schedule output")
|
||||
return fmt.Errorf("setting backup schedule: %w", err)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func RemoveServiceBackupSchedule(e *env.Env, c echo.Context) error {
|
||||
var req dto.ManageServiceRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf(`%s:backup-unschedule %s`, req.Type, req.Name)
|
||||
if out, err := e.Dokku.Exec(cmd); err != nil {
|
||||
log.Debug().Str("output", out).Msg("backup schedule output")
|
||||
return fmt.Errorf("removing backup schedule: %w", err)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func SetServiceBackupEncryption(e *env.Env, c echo.Context) error {
|
||||
var req dto.SetServiceBackupsEncryptionRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
dbSvc, err := lookupDBServiceByName(e, req.Name)
|
||||
if err != nil {
|
||||
return echo.ErrNotFound
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf(`%s:backup-set-encryption %s %s`, dbSvc.Type,
|
||||
req.Name, req.Passphrase)
|
||||
if out, err := e.Dokku.Exec(cmd); err != nil {
|
||||
log.Debug().Str("output", out).Msg("set backup encryption output")
|
||||
return fmt.Errorf("setting backup encryption: %w", err)
|
||||
}
|
||||
|
||||
dbSvc.BackupEncryptionSet = true
|
||||
if saveErr := e.DB.Save(&dbSvc).Error; saveErr != nil {
|
||||
log.Error().Err(saveErr).Msg("error updating service backup encryption")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func RemoveServiceBackupEncryption(e *env.Env, c echo.Context) error {
|
||||
var req dto.ManageServiceRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
dbSvc, err := lookupDBServiceByName(e, req.Name)
|
||||
if err != nil {
|
||||
return echo.ErrNotFound
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf(`%s:backup-unset-encryption %s`, req.Type, req.Name)
|
||||
if out, err := e.Dokku.Exec(cmd); err != nil {
|
||||
log.Debug().Str("output", out).Msg("unset backup encryption output")
|
||||
return fmt.Errorf("removing backup encryption: %w", err)
|
||||
}
|
||||
|
||||
dbSvc.BackupEncryptionSet = false
|
||||
if saveErr := e.DB.Save(&dbSvc).Error; saveErr != nil {
|
||||
log.Error().Err(saveErr).Msg("error updating service backup encryption")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
167
internal/server/api/services/management.go
Normal file
167
internal/server/api/services/management.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/texm/dokku-go"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/models"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDestroyingLinkedService = echo.NewHTTPError(http.StatusBadRequest,
|
||||
"cannot destroy a linked service")
|
||||
ErrServiceNameTaken = echo.NewHTTPError(http.StatusBadRequest,
|
||||
"service name exists")
|
||||
)
|
||||
|
||||
func manageService(e *env.Env, c echo.Context, mgmt string, flags string) error {
|
||||
var req dto.GenericServiceRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("%s:%s %s %s", req.Type, mgmt, req.Name, flags)
|
||||
if _, err := e.Dokku.Exec(cmd); err != nil {
|
||||
return fmt.Errorf(mgmt+"ing service", err)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func StartService(e *env.Env, c echo.Context) error {
|
||||
return manageService(e, c, "start", "")
|
||||
}
|
||||
func StopService(e *env.Env, c echo.Context) error {
|
||||
return manageService(e, c, "stop", "")
|
||||
}
|
||||
func RestartService(e *env.Env, c echo.Context) error {
|
||||
return manageService(e, c, "restart", "")
|
||||
}
|
||||
|
||||
func DestroyService(e *env.Env, c echo.Context) error {
|
||||
var req dto.GenericServiceRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
dbSvc, lookupErr := lookupDBServiceByName(e, req.Name)
|
||||
if lookupErr != nil {
|
||||
return echo.ErrNotFound
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("%s:destroy %s -f", req.Type, req.Name)
|
||||
if _, err := e.Dokku.Exec(cmd); err != nil {
|
||||
var dokkuErr *dokku.ExitCodeError
|
||||
if errors.As(err, &dokkuErr) {
|
||||
if strings.HasSuffix(dokkuErr.Output(), "Cannot delete linked service") {
|
||||
return ErrDestroyingLinkedService
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("destroying service: %w", err)
|
||||
}
|
||||
|
||||
if err := e.DB.Delete(&dbSvc).Error; err != nil {
|
||||
log.Error().Err(err).Interface("svc", dbSvc).Msg("error deleting service")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
// use short opt strings otherwise arg parse is funky
|
||||
func maybeAppendStringOptionFlag(flags *[]string, opt string, cfgOption *string) {
|
||||
if cfgOption != nil {
|
||||
*flags = append(*flags, fmt.Sprintf("-%s '%s'", opt, *cfgOption))
|
||||
}
|
||||
}
|
||||
|
||||
func getServiceCreateFlags(req dto.GenericServiceCreationConfig) string {
|
||||
flags := &[]string{}
|
||||
|
||||
if req.CustomEnv != nil {
|
||||
envVars := make([]string, len(*req.CustomEnv))
|
||||
for i, e := range *req.CustomEnv {
|
||||
envVars[i] = fmt.Sprintf("%s=%s", e[0], e[1])
|
||||
}
|
||||
envStr := strings.Join(envVars, ";")
|
||||
*flags = append(*flags, fmt.Sprintf("-C \"%s\"", envStr))
|
||||
}
|
||||
|
||||
maybeAppendStringOptionFlag(flags, "c", req.ConfigOptions)
|
||||
maybeAppendStringOptionFlag(flags, "i", req.Image)
|
||||
maybeAppendStringOptionFlag(flags, "m", req.MemoryLimit)
|
||||
maybeAppendStringOptionFlag(flags, "p", req.Password)
|
||||
maybeAppendStringOptionFlag(flags, "r", req.RootPassword)
|
||||
maybeAppendStringOptionFlag(flags, "s", req.SharedMemorySize)
|
||||
|
||||
return strings.Join(*flags, " ")
|
||||
}
|
||||
|
||||
func CreateNewGenericService(e *env.Env, c echo.Context) error {
|
||||
var req dto.CreateGenericServiceRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
log.Debug().Err(err.ToHTTP()).Interface("req", req).Msgf("req failed")
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
dbSvc := models.Service{
|
||||
Name: req.Name,
|
||||
Type: req.ServiceType,
|
||||
}
|
||||
if e.DB.Where("name = ?", req.Name).Find(&dbSvc).RowsAffected != 0 {
|
||||
return ErrServiceNameTaken
|
||||
}
|
||||
|
||||
e.DB.Save(&dbSvc)
|
||||
|
||||
flags := getServiceCreateFlags(req.Config)
|
||||
cmd := fmt.Sprintf("%s:create %s %s", req.ServiceType, req.Name, flags)
|
||||
_, err := e.Dokku.Exec(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating %s service: %w", req.ServiceType, err)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func CloneService(e *env.Env, c echo.Context) error {
|
||||
var req dto.CloneServiceRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
dbSvc, notFoundErr := lookupDBServiceByName(e, req.Name)
|
||||
if notFoundErr != nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "service not found")
|
||||
}
|
||||
|
||||
_, notFoundErr = lookupDBServiceByName(e, req.NewName)
|
||||
if notFoundErr == nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "new service name exists")
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("%s:clone %s %s", dbSvc.Type, req.Name, req.NewName)
|
||||
_, err := e.Dokku.Exec(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cloning %s service: %w", dbSvc.Type, err)
|
||||
}
|
||||
|
||||
newSvc := models.Service{
|
||||
Name: req.NewName,
|
||||
Type: dbSvc.Type,
|
||||
}
|
||||
if err := e.DB.Save(&newSvc).Error; err != nil {
|
||||
log.Error().Err(err).
|
||||
Str("name", req.NewName).Str("type", dbSvc.Type).
|
||||
Msg("failed to save cloned service to db")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
34
internal/server/api/services/routes.go
Normal file
34
internal/server/api/services/routes.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
)
|
||||
|
||||
func RegisterRoutes(e *env.Env, g *echo.Group) {
|
||||
g.GET("/list", e.H(ListServices))
|
||||
g.GET("/info", e.H(GetServiceInfo))
|
||||
g.GET("/type", e.H(GetServiceType))
|
||||
g.GET("/logs", e.H(GetServiceLogs))
|
||||
|
||||
g.POST("/create", e.H(CreateNewGenericService))
|
||||
g.POST("/clone", e.H(CloneService))
|
||||
g.POST("/start", e.H(StartService))
|
||||
g.POST("/stop", e.H(StopService))
|
||||
g.POST("/restart", e.H(RestartService))
|
||||
g.POST("/destroy", e.H(DestroyService))
|
||||
|
||||
g.POST("/link", e.H(LinkGenericServiceToApp))
|
||||
g.POST("/unlink", e.H(UnlinkGenericServiceFromApp))
|
||||
g.GET("/linked-apps", e.H(GetServiceLinkedApps))
|
||||
|
||||
backups := g.Group("/backups")
|
||||
backups.GET("/report", e.H(GetServiceBackupReport))
|
||||
backups.POST("/auth", e.H(SetServiceBackupAuth))
|
||||
backups.POST("/bucket", e.H(SetServiceBackupBucket))
|
||||
backups.POST("/run", e.H(RunServiceBackup))
|
||||
backups.POST("/schedule", e.H(SetServiceBackupSchedule))
|
||||
backups.DELETE("/schedule", e.H(RemoveServiceBackupSchedule))
|
||||
backups.POST("/encryption", e.H(SetServiceBackupEncryption))
|
||||
backups.DELETE("/encryption", e.H(RemoveServiceBackupEncryption))
|
||||
}
|
||||
220
internal/server/api/services/services.go
Normal file
220
internal/server/api/services/services.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/texm/dokku-go"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/models"
|
||||
"gitlab.com/texm/shokku/internal/server/commands"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
dokkuErrPrefix = "! "
|
||||
serviceTypes = []string{"redis", "postgres", "mysql", "mongo"}
|
||||
)
|
||||
|
||||
func lookupDBServiceByName(e *env.Env, name string) (*models.Service, error) {
|
||||
dbSvc := models.Service{
|
||||
Name: name,
|
||||
}
|
||||
res := e.DB.Where("name = ?", name).Find(&dbSvc)
|
||||
if res.Error != nil {
|
||||
return nil, res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return nil, fmt.Errorf("no service found for %s", name)
|
||||
}
|
||||
return &dbSvc, nil
|
||||
}
|
||||
|
||||
func splitDokkuListOutput(output string) ([]string, error) {
|
||||
if strings.HasPrefix(output, dokkuErrPrefix) {
|
||||
return nil, nil
|
||||
}
|
||||
if output == "" {
|
||||
return []string{}, nil
|
||||
}
|
||||
return strings.Split(output, "\n"), nil
|
||||
}
|
||||
|
||||
func getServiceAppLinks(e *env.Env, serviceName string, serviceType string) ([]string, error) {
|
||||
linksCmd := fmt.Sprintf("%s:links %s --quiet", serviceType, serviceName)
|
||||
out, err := e.Dokku.Exec(linksCmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return splitDokkuListOutput(out)
|
||||
}
|
||||
|
||||
func getServiceList(e *env.Env, serviceType string) ([]string, error) {
|
||||
listCmd := fmt.Sprintf("%s:list --quiet", serviceType)
|
||||
out, err := e.Dokku.Exec(listCmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.Contains(out, "There are no") {
|
||||
return []string{}, nil
|
||||
}
|
||||
return splitDokkuListOutput(out)
|
||||
}
|
||||
|
||||
func ListServices(e *env.Env, c echo.Context) error {
|
||||
serviceList := []dto.ServiceInfo{}
|
||||
|
||||
for _, serviceType := range serviceTypes {
|
||||
services, err := getServiceList(e, serviceType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting list for %s services: %w", serviceType, err)
|
||||
}
|
||||
for _, name := range services {
|
||||
serviceList = append(serviceList, dto.ServiceInfo{
|
||||
Name: name,
|
||||
Type: serviceType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.ListServicesResponse{
|
||||
Services: serviceList,
|
||||
})
|
||||
}
|
||||
|
||||
func GetServiceType(e *env.Env, c echo.Context) error {
|
||||
var req dto.GetServiceTypeRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
dbSvc, err := lookupDBServiceByName(e, req.Name)
|
||||
if err != nil {
|
||||
return echo.ErrNotFound
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.GetServiceTypeResponse{
|
||||
Type: dbSvc.Type,
|
||||
})
|
||||
}
|
||||
|
||||
func GetServiceInfo(e *env.Env, c echo.Context) error {
|
||||
var req dto.GenericServiceRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
info := map[string]string{
|
||||
"version": "", "internal-ip": "", "status": "", // "dsn": "",
|
||||
}
|
||||
for key := range info {
|
||||
cmd := fmt.Sprintf("%s:info %s --%s", req.Type, req.Name, key)
|
||||
out, err := e.Dokku.Exec(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting service info: %w", err)
|
||||
}
|
||||
info[key] = out
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.GetServiceInfoResponse{
|
||||
Info: info,
|
||||
})
|
||||
}
|
||||
|
||||
func getGenericLinkFlags(req dto.LinkGenericServiceToAppRequest) string {
|
||||
var flags []string
|
||||
if req.Alias != "" {
|
||||
flag := fmt.Sprintf("--alias %s", req.Alias)
|
||||
flags = append(flags, flag)
|
||||
}
|
||||
if req.QueryString != "" {
|
||||
flag := fmt.Sprintf("--querystring %s", req.QueryString)
|
||||
flags = append(flags, flag)
|
||||
}
|
||||
return strings.Join(flags, " ")
|
||||
}
|
||||
|
||||
func LinkGenericServiceToApp(e *env.Env, c echo.Context) error {
|
||||
var req dto.LinkGenericServiceToAppRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
dbSvc, err := lookupDBServiceByName(e, req.ServiceName)
|
||||
if err != nil {
|
||||
return echo.ErrNotFound
|
||||
}
|
||||
|
||||
flags := getGenericLinkFlags(req)
|
||||
linkCmd := fmt.Sprintf("%s:link %s %s %s",
|
||||
dbSvc.Type, dbSvc.Name, req.AppName, flags)
|
||||
|
||||
cmd := func() (*dokku.CommandOutputStream, error) {
|
||||
return e.Dokku.ExecStreaming(linkCmd)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.CommandExecutionResponse{
|
||||
ExecutionID: commands.RequestExecution(cmd, nil),
|
||||
})
|
||||
}
|
||||
|
||||
func UnlinkGenericServiceFromApp(e *env.Env, c echo.Context) error {
|
||||
var req dto.LinkGenericServiceToAppRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
dbSvc, err := lookupDBServiceByName(e, req.ServiceName)
|
||||
if err != nil {
|
||||
return echo.ErrNotFound
|
||||
}
|
||||
|
||||
unlinkCmd := fmt.Sprintf("%s:unlink %s %s", dbSvc.Type,
|
||||
dbSvc.Name, req.AppName)
|
||||
|
||||
cmd := func() (*dokku.CommandOutputStream, error) {
|
||||
return e.Dokku.ExecStreaming(unlinkCmd)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.CommandExecutionResponse{
|
||||
ExecutionID: commands.RequestExecution(cmd, nil),
|
||||
})
|
||||
}
|
||||
|
||||
func GetServiceLinkedApps(e *env.Env, c echo.Context) error {
|
||||
var req dto.GenericServiceRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
log.Error().Err(err.ToHTTP()).Msg("error")
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
apps, err := getServiceAppLinks(e, req.Name, req.Type)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting linked apps: %w", err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.GetServiceLinkedAppsResponse{
|
||||
Apps: apps,
|
||||
})
|
||||
}
|
||||
|
||||
func GetServiceLogs(e *env.Env, c echo.Context) error {
|
||||
var req dto.GenericServiceRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
log.Error().Err(err.ToHTTP()).Msg("error")
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("%s:logs %s", req.Type, req.Name)
|
||||
out, err := e.Dokku.Exec(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting linked apps: %w", err)
|
||||
}
|
||||
|
||||
logs := strings.Split(out, "\n")
|
||||
return c.JSON(http.StatusOK, dto.GetServiceLogsResponse{
|
||||
Logs: logs,
|
||||
})
|
||||
}
|
||||
46
internal/server/api/settings/networks.go
Normal file
46
internal/server/api/settings/networks.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func CreateNetwork(e *env.Env, c echo.Context) error {
|
||||
var req dto.AlterNetworkRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
if err := e.Dokku.CreateNetwork(req.Network); err != nil {
|
||||
return fmt.Errorf("error creating network: %w", err)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func DestroyNetwork(e *env.Env, c echo.Context) error {
|
||||
var req dto.AlterNetworkRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
if err := e.Dokku.DestroyNetwork(req.Network); err != nil {
|
||||
return fmt.Errorf("error destroying network: %w", err)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func ListNetworks(e *env.Env, c echo.Context) error {
|
||||
networks, err := e.Dokku.ListNetworks()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error listing networks: %w", err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.ListNetworksResponse{
|
||||
Networks: networks,
|
||||
})
|
||||
}
|
||||
75
internal/server/api/settings/registry.go
Normal file
75
internal/server/api/settings/registry.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/texm/dokku-go"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func SetDockerRegistry(e *env.Env, c echo.Context) error {
|
||||
var req dto.SetDockerRegistryRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
if err := e.Dokku.LoginDockerRegistry(req.Server, req.Username, req.Password); err != nil {
|
||||
return echo.NewHTTPError(http.StatusForbidden, err.Error())
|
||||
}
|
||||
|
||||
propErr := e.Dokku.SetAppDockerRegistryProperty("--global", dokku.DockerRegistryPropertyServer, req.Server)
|
||||
if propErr != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, propErr.Error())
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func GetDockerRegistryReport(e *env.Env, c echo.Context) error {
|
||||
report, err := e.Dokku.GetDockerRegistryReport()
|
||||
if err != nil && !errors.Is(err, dokku.NoDeployedAppsError) {
|
||||
return fmt.Errorf("failed to get registry report: %w", err)
|
||||
}
|
||||
|
||||
var response dto.GetDockerRegistryReportResponse
|
||||
for _, appReport := range report {
|
||||
response.Server = appReport.GlobalServer
|
||||
response.PushOnRelease = appReport.GlobalPushOnRelease
|
||||
break
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func AddGitAuth(e *env.Env, c echo.Context) error {
|
||||
var req dto.AddGitAuthRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
if err := e.Dokku.GitSetAuth(req.Host, req.Username, req.Password); err != nil {
|
||||
return echo.NewHTTPError(http.StatusForbidden, err.Error())
|
||||
}
|
||||
|
||||
if err := e.Dokku.GitAllowHost(req.Host); err != nil {
|
||||
return echo.NewHTTPError(http.StatusForbidden, err.Error())
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func RemoveGitAuth(e *env.Env, c echo.Context) error {
|
||||
var req dto.RemoveGitAuthRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
if err := e.Dokku.GitRemoveAuth(req.Host); err != nil {
|
||||
return echo.NewHTTPError(http.StatusForbidden, err.Error())
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
33
internal/server/api/settings/routes.go
Normal file
33
internal/server/api/settings/routes.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
)
|
||||
|
||||
func RegisterRoutes(e *env.Env, g *echo.Group) {
|
||||
g.GET("/versions", e.H(GetVersions))
|
||||
|
||||
g.GET("/events", e.H(GetEventLogs))
|
||||
g.GET("/events/list", e.H(GetEventLogsList))
|
||||
g.POST("/events", e.H(SetEventLoggingEnabled))
|
||||
|
||||
g.GET("/users", e.H(GetUsers))
|
||||
g.GET("/ssh-keys", e.H(GetSSHKeys))
|
||||
|
||||
g.GET("/domains", e.H(GetGlobalDomains))
|
||||
g.POST("/domains", e.H(AddGlobalDomain))
|
||||
g.DELETE("/domains", e.H(RemoveGlobalDomain))
|
||||
|
||||
g.GET("/networks", e.H(ListNetworks))
|
||||
g.POST("/networks", e.H(CreateNetwork))
|
||||
g.DELETE("/networks", e.H(DestroyNetwork))
|
||||
|
||||
g.GET("/plugins", e.H(ListPlugins))
|
||||
|
||||
g.GET("/registry", e.H(GetDockerRegistryReport))
|
||||
g.POST("/registry", e.H(SetDockerRegistry))
|
||||
|
||||
g.POST("/git-auth", e.H(AddGitAuth))
|
||||
g.DELETE("/git-auth", e.H(RemoveGitAuth))
|
||||
}
|
||||
156
internal/server/api/settings/settings.go
Normal file
156
internal/server/api/settings/settings.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/models"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func GetVersions(e *env.Env, c echo.Context) error {
|
||||
dokkuVersion, err := e.Dokku.GetDokkuVersion()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting dokku version: %w", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, &dto.GetVersionsResponse{
|
||||
Dokku: dokkuVersion,
|
||||
Shokku: e.Version,
|
||||
})
|
||||
}
|
||||
|
||||
func GetUsers(e *env.Env, c echo.Context) error {
|
||||
var dbUsers []models.User
|
||||
res := e.DB.Model(models.User{}).Preload("SSHKeys").Find(&dbUsers)
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("querying db users: %w", res.Error)
|
||||
}
|
||||
|
||||
users := make([]dto.User, len(dbUsers))
|
||||
for i, dbUser := range dbUsers {
|
||||
keys := make([]string, len(dbUser.SSHKeys))
|
||||
for j, key := range dbUser.SSHKeys {
|
||||
keys[j] = key.Key
|
||||
}
|
||||
users[i] = dto.User{
|
||||
Name: dbUser.Name,
|
||||
Source: dbUser.Source,
|
||||
SSHKeys: keys,
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.GetUsersResponse{
|
||||
Users: users,
|
||||
})
|
||||
}
|
||||
|
||||
func GetSSHKeys(e *env.Env, c echo.Context) error {
|
||||
keys, err := e.Dokku.ListSSHKeys()
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing ssh keys: %w", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, &dto.GetSSHKeysResponse{
|
||||
Keys: keys,
|
||||
})
|
||||
}
|
||||
|
||||
func GetGlobalDomains(e *env.Env, c echo.Context) error {
|
||||
report, err := e.Dokku.GetGlobalDomainsReport()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting global domains report: %w", err)
|
||||
}
|
||||
|
||||
if len(report.Domains) == 0 {
|
||||
report.Domains = make([]string, 0)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, &dto.GetGlobalDomainsResponse{
|
||||
Domains: report.Domains,
|
||||
Enabled: report.Enabled,
|
||||
})
|
||||
}
|
||||
|
||||
func AddGlobalDomain(e *env.Env, c echo.Context) error {
|
||||
var req dto.AlterGlobalDomainRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
// TODO: domain verification etc
|
||||
if err := e.Dokku.AddGlobalDomain(req.Domain); err != nil {
|
||||
return fmt.Errorf("adding global domain: %w", err)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func RemoveGlobalDomain(e *env.Env, c echo.Context) error {
|
||||
var req dto.DeleteGlobalDomainRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
// TODO: domain verification etc
|
||||
if err := e.Dokku.RemoveGlobalDomain(req.Domain); err != nil {
|
||||
return fmt.Errorf("removing global domain: %w", err)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func SetEventLoggingEnabled(e *env.Env, c echo.Context) error {
|
||||
var req dto.SetEventLoggingEnabledRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
if err := e.Dokku.SetEventLoggingEnabled(req.Enabled); err != nil {
|
||||
return fmt.Errorf("setting event logging: %w", err)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func GetEventLogsList(e *env.Env, c echo.Context) error {
|
||||
events, err := e.Dokku.ListLoggedEvents()
|
||||
if err != nil {
|
||||
return fmt.Errorf("removing global domain: %w", err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.GetEventLogsListResponse{
|
||||
Events: events,
|
||||
})
|
||||
}
|
||||
|
||||
func GetEventLogs(e *env.Env, c echo.Context) error {
|
||||
logs, err := e.Dokku.GetEventLogs()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting event logs: %w", err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.GetEventLogsResponse{
|
||||
Logs: logs,
|
||||
})
|
||||
}
|
||||
|
||||
func ListPlugins(e *env.Env, c echo.Context) error {
|
||||
plugins, err := e.Dokku.ListPlugins()
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing plugins: %w", err)
|
||||
}
|
||||
info := make([]dto.PluginInfo, len(plugins))
|
||||
for i := 0; i < len(plugins); i++ {
|
||||
p := plugins[i]
|
||||
info[i] = dto.PluginInfo{
|
||||
Name: p.Name,
|
||||
Version: p.Version,
|
||||
Enabled: p.Enabled,
|
||||
Description: p.Description,
|
||||
}
|
||||
}
|
||||
return c.JSON(http.StatusOK, &dto.ListPluginsResponse{
|
||||
Plugins: info,
|
||||
})
|
||||
}
|
||||
111
internal/server/api/setup/github.go
Normal file
111
internal/server/api/setup/github.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/models"
|
||||
"gitlab.com/texm/shokku/internal/server/auth"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
"gitlab.com/texm/shokku/internal/server/github"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
githubAppInstallURL = "https://github.com/apps/%s/installations/new/permissions?target_id=%d"
|
||||
)
|
||||
|
||||
func GetGithubSetupStatus(e *env.Env, c echo.Context) error {
|
||||
var ghApp models.GithubApp
|
||||
r := e.DB.Find(&ghApp)
|
||||
if r.Error != nil && !errors.Is(r.Error, sql.ErrNoRows) {
|
||||
log.Error().Err(r.Error).Msg("Failed to lookup github app")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.GetGithubSetupStatus{
|
||||
AppCreated: r.RowsAffected > 0,
|
||||
})
|
||||
}
|
||||
|
||||
func CreateGithubApp(e *env.Env, c echo.Context) error {
|
||||
var req dto.CreateGithubAppRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
cfg, err := github.CompleteAppManifest(e, req.Code)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.CreateGithubAppResponse{
|
||||
Slug: cfg.GetSlug(),
|
||||
})
|
||||
}
|
||||
|
||||
func GetGithubAppInstallInfo(e *env.Env, c echo.Context) error {
|
||||
ctx := context.Background()
|
||||
|
||||
client, clientErr := github.GetAppClient(e)
|
||||
if clientErr != nil {
|
||||
log.Error().Err(clientErr).Msg("failed to get app client")
|
||||
return echo.NewHTTPError(http.StatusBadRequest, clientErr)
|
||||
}
|
||||
|
||||
app, appErr := client.GetApp(ctx)
|
||||
if appErr != nil {
|
||||
log.Error().Err(appErr).Msg("failed to get github client app")
|
||||
return echo.NewHTTPError(http.StatusBadRequest, appErr)
|
||||
}
|
||||
url := fmt.Sprintf(githubAppInstallURL, app.GetSlug(), app.Owner.GetID())
|
||||
|
||||
return c.JSON(http.StatusOK, dto.InstallGithubAppResponse{
|
||||
InstallURL: url,
|
||||
})
|
||||
}
|
||||
|
||||
func CompleteGithubSetup(e *env.Env, c echo.Context) error {
|
||||
var req dto.CompleteGithubSetupRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
client, clientErr := github.GetAppClient(e)
|
||||
if clientErr != nil {
|
||||
log.Error().Err(clientErr).Msg("failed to get app client")
|
||||
return echo.ErrBadRequest
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
inst, _, instErr := client.Apps.GetInstallation(ctx, req.InstallationId)
|
||||
if instErr != nil {
|
||||
log.Error().
|
||||
Err(instErr).
|
||||
Int64("id", req.InstallationId).
|
||||
Msg("failed to get installation")
|
||||
return echo.ErrBadRequest
|
||||
}
|
||||
|
||||
if err := setupServerWithAuthMethod(e, auth.MethodGithub); err != nil {
|
||||
log.Error().Err(err).Msg("failed to setup github auth")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := github.SyncUsersToDB(e); err != nil {
|
||||
log.Error().Err(err).Msg("failed to sync github users")
|
||||
}
|
||||
}()
|
||||
|
||||
log.Debug().
|
||||
Int64("id", inst.GetID()).
|
||||
Msg("installed github app")
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
101
internal/server/api/setup/password.go
Normal file
101
internal/server/api/setup/password.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gitlab.com/texm/shokku/internal/models"
|
||||
"gitlab.com/texm/shokku/internal/server/auth"
|
||||
"image/png"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
)
|
||||
|
||||
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
|
||||
func generateRandomString(n int) string {
|
||||
b := make([]rune, n)
|
||||
for i := range b {
|
||||
b[i] = letterRunes[rand.Intn(len(letterRunes))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func GenerateTotp(e *env.Env, c echo.Context) error {
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: "shokku",
|
||||
AccountName: "admin account",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate totp key: %w", err)
|
||||
}
|
||||
|
||||
var imgBuf bytes.Buffer
|
||||
img, imgErr := key.Image(160, 160)
|
||||
if imgErr != nil {
|
||||
return fmt.Errorf("failed to generate totp image: %w", imgErr)
|
||||
}
|
||||
if encErr := png.Encode(&imgBuf, img); encErr != nil {
|
||||
return fmt.Errorf("failed to encode totp image to png: %w", encErr)
|
||||
}
|
||||
return c.JSON(http.StatusOK, dto.GenerateTotpResponse{
|
||||
Secret: key.Secret(),
|
||||
Image: base64.StdEncoding.EncodeToString(imgBuf.Bytes()),
|
||||
RecoveryCode: generateRandomString(12),
|
||||
})
|
||||
}
|
||||
|
||||
func ConfirmTotp(e *env.Env, c echo.Context) error {
|
||||
var req dto.ConfirmTotpRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.ConfirmTotpResponse{
|
||||
Valid: totp.Validate(req.Code, req.Secret),
|
||||
})
|
||||
}
|
||||
|
||||
func CompletePasswordSetup(e *env.Env, c echo.Context) error {
|
||||
var req dto.CompletePasswordSetupRequest
|
||||
if err := dto.BindRequest(c, &req); err != nil {
|
||||
return err.ToHTTP()
|
||||
}
|
||||
|
||||
if err := setupServerWithAuthMethod(e, auth.MethodPassword); err != nil {
|
||||
log.Error().Err(err).Msg("failed to setup github auth")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
pw, ok := e.Auth.(*auth.PasswordAuthenticator)
|
||||
if !ok {
|
||||
log.Error().Msg("failed to cast e.Auth to pw auth")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
passwordHash, pwErr := pw.HashPassword([]byte(req.Password))
|
||||
if pwErr != nil {
|
||||
log.Error().Err(pwErr).Msg("failed to hash password")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
user := models.User{
|
||||
Name: req.Username,
|
||||
Source: "manual",
|
||||
PasswordHash: passwordHash,
|
||||
TotpEnabled: req.Enable2FA,
|
||||
RecoveryCode: req.RecoveryCode,
|
||||
TotpSecret: req.TotpSecret,
|
||||
}
|
||||
if err := e.DB.Save(&user).Error; err != nil {
|
||||
log.Error().Err(err).Msg("failed to save initial password auth user")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
20
internal/server/api/setup/routes.go
Normal file
20
internal/server/api/setup/routes.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
)
|
||||
|
||||
func RegisterRoutes(e *env.Env, g *echo.Group) {
|
||||
g.GET("/status", e.H(GetStatus))
|
||||
g.GET("/verify-key", e.H(GetSetupKeyValid))
|
||||
|
||||
g.POST("/github/create-app", e.H(CreateGithubApp))
|
||||
g.GET("/github/install-info", e.H(GetGithubAppInstallInfo))
|
||||
g.GET("/github/status", e.H(GetGithubSetupStatus))
|
||||
g.POST("/github/completed", e.H(CompleteGithubSetup))
|
||||
|
||||
g.POST("/password", e.H(CompletePasswordSetup))
|
||||
g.POST("/totp/new", e.H(GenerateTotp))
|
||||
g.POST("/totp/confirm", e.H(ConfirmTotp))
|
||||
}
|
||||
65
internal/server/api/setup/setup.go
Normal file
65
internal/server/api/setup/setup.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/models"
|
||||
"gitlab.com/texm/shokku/internal/server/auth"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func GetStatus(e *env.Env, c echo.Context) error {
|
||||
method := string(e.Auth.GetMethod())
|
||||
log.Debug().
|
||||
Bool("is_setup", e.SetupCompleted).
|
||||
Str("method", method).
|
||||
Msg("get setup status")
|
||||
return c.JSON(http.StatusOK, dto.GetSetupStatusResponse{
|
||||
IsSetup: e.SetupCompleted,
|
||||
Method: method,
|
||||
})
|
||||
}
|
||||
|
||||
func GetSetupKeyValid(e *env.Env, c echo.Context) error {
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func setupServerWithAuthMethod(e *env.Env, method auth.Method) error {
|
||||
var state models.Server
|
||||
e.DB.FirstOrCreate(&state)
|
||||
|
||||
state.IsSetup = true
|
||||
state.AuthMethod = method
|
||||
if err := e.DB.Save(&state).Error; err != nil {
|
||||
log.Error().Err(err).Msg("failed to save setup state")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
newAuth, authErr := createAuthenticator(e, method)
|
||||
if authErr != nil {
|
||||
log.Error().Err(authErr).Msg("failed to init new authenticator")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
e.Auth = newAuth
|
||||
e.SetupCompleted = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createAuthenticator(e *env.Env, method auth.Method) (auth.Authenticator, error) {
|
||||
config := auth.Config{
|
||||
SigningKey: e.Auth.GetSigningKey(),
|
||||
CookieDomain: e.Auth.GetCookieDomain(),
|
||||
TokenLifetime: e.Auth.GetTokenLifetime(),
|
||||
}
|
||||
switch method {
|
||||
case auth.MethodGithub:
|
||||
return auth.NewGithubAuthenticator(config)
|
||||
case auth.MethodPassword:
|
||||
return auth.NewPasswordAuthenticator(config, auth.DefaultBCryptCost)
|
||||
}
|
||||
return nil, fmt.Errorf("unknown method %s", method)
|
||||
}
|
||||
43
internal/server/auth.go
Normal file
43
internal/server/auth.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitlab.com/texm/shokku/internal/server/auth"
|
||||
"time"
|
||||
)
|
||||
|
||||
type initAuthConfig struct {
|
||||
SigningKey []byte
|
||||
TokenLifetime time.Duration
|
||||
Method auth.Method
|
||||
DebugMode bool
|
||||
IsSetup bool
|
||||
}
|
||||
|
||||
func initAuthenticator(cfg initAuthConfig) (auth.Authenticator, error) {
|
||||
authCfg := auth.Config{
|
||||
SigningKey: cfg.SigningKey,
|
||||
TokenLifetime: cfg.TokenLifetime,
|
||||
}
|
||||
|
||||
if !cfg.IsSetup {
|
||||
return auth.NewNoneAuthenticator(authCfg)
|
||||
}
|
||||
|
||||
bCryptCost := 14
|
||||
if cfg.DebugMode {
|
||||
// make hashing faster in dev
|
||||
bCryptCost = 3
|
||||
}
|
||||
|
||||
switch cfg.Method {
|
||||
case auth.MethodPassword:
|
||||
return auth.NewPasswordAuthenticator(authCfg, bCryptCost)
|
||||
case auth.MethodGithub:
|
||||
return auth.NewGithubAuthenticator(authCfg)
|
||||
case auth.MethodNone:
|
||||
return auth.NewNoneAuthenticator(authCfg)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unsupported auth method '%s'", cfg.Method)
|
||||
}
|
||||
151
internal/server/auth/auth.go
Normal file
151
internal/server/auth/auth.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ContextUserKey = "user"
|
||||
DataCookieName = "auth_data"
|
||||
SignatureCookieName = "auth_sig"
|
||||
)
|
||||
|
||||
type Method string
|
||||
|
||||
const (
|
||||
MethodNone = Method("none")
|
||||
MethodPassword = Method("password")
|
||||
MethodGithub = Method("github")
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoTokenInContext = errors.New("no token value in context")
|
||||
ErrNoUserInContext = errors.New("failed to retrieve user from context")
|
||||
ErrClaimsInvalid = errors.New("failed to cast jwt claims")
|
||||
)
|
||||
|
||||
type Authenticator interface {
|
||||
NewToken(claims UserClaims) (string, error)
|
||||
SetUserContext(c echo.Context, contextKey string) error
|
||||
GetUserFromContext(c echo.Context) (*User, error)
|
||||
SetTokenCookies(c echo.Context, jwt string) string
|
||||
ClearTokenCookies(c echo.Context)
|
||||
GetSigningKey() []byte
|
||||
GetCookieDomain() string
|
||||
GetTokenLifetime() time.Duration
|
||||
GetMethod() Method
|
||||
}
|
||||
|
||||
type User struct {
|
||||
UserClaims
|
||||
jwt.StandardClaims
|
||||
}
|
||||
|
||||
type UserClaims struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type baseAuthenticator struct {
|
||||
signingKey []byte
|
||||
authMethod Method
|
||||
cookieDomain string
|
||||
tokenLifetime time.Duration
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
SigningKey []byte
|
||||
CookieDomain string
|
||||
TokenLifetime time.Duration
|
||||
}
|
||||
|
||||
func (a *baseAuthenticator) NewToken(claims UserClaims) (string, error) {
|
||||
expiry := time.Now().Add(a.tokenLifetime)
|
||||
stdClaims := jwt.StandardClaims{
|
||||
ExpiresAt: expiry.Unix(),
|
||||
}
|
||||
|
||||
user := &User{
|
||||
UserClaims: claims,
|
||||
StandardClaims: stdClaims,
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, user)
|
||||
return token.SignedString(a.signingKey)
|
||||
}
|
||||
|
||||
func (a *baseAuthenticator) SetUserContext(c echo.Context, contextKey string) error {
|
||||
token, ok := c.Get(contextKey).(*jwt.Token)
|
||||
if !ok {
|
||||
return ErrNoTokenInContext
|
||||
}
|
||||
|
||||
u, ok := token.Claims.(*User)
|
||||
if !ok {
|
||||
return ErrClaimsInvalid
|
||||
}
|
||||
|
||||
c.Set(ContextUserKey, u)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *baseAuthenticator) GetUserFromContext(c echo.Context) (*User, error) {
|
||||
user, ok := c.Get(ContextUserKey).(*User)
|
||||
if !ok {
|
||||
log.Error().Msg("failed to retrieve user from context")
|
||||
return nil, ErrNoUserInContext
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (a *baseAuthenticator) SetTokenCookies(c echo.Context, jwt string) string {
|
||||
splitToken := strings.Split(jwt, ".")
|
||||
dataCookieValue := strings.Join(splitToken[:2], ".")
|
||||
signatureCookieValue := splitToken[2]
|
||||
|
||||
// accessible to the js frontend
|
||||
dataCookieValues := authCookieValues{
|
||||
name: DataCookieName,
|
||||
value: dataCookieValue,
|
||||
httpOnly: false,
|
||||
lifetime: a.tokenLifetime,
|
||||
}
|
||||
c.SetCookie(makeAuthCookie(dataCookieValues))
|
||||
|
||||
// inaccessible to the js frontend
|
||||
signatureCookieValues := authCookieValues{
|
||||
name: SignatureCookieName,
|
||||
value: signatureCookieValue,
|
||||
httpOnly: true,
|
||||
lifetime: a.tokenLifetime,
|
||||
}
|
||||
c.SetCookie(makeAuthCookie(signatureCookieValues))
|
||||
|
||||
return dataCookieValue
|
||||
}
|
||||
|
||||
func (a *baseAuthenticator) ClearTokenCookies(c echo.Context) {
|
||||
c.SetCookie(clearAuthCookie(DataCookieName, false, a.cookieDomain))
|
||||
c.SetCookie(clearAuthCookie(SignatureCookieName, true, a.cookieDomain))
|
||||
}
|
||||
|
||||
func (a *baseAuthenticator) GetSigningKey() []byte {
|
||||
return a.signingKey
|
||||
}
|
||||
|
||||
func (a *baseAuthenticator) GetMethod() Method {
|
||||
return a.authMethod
|
||||
}
|
||||
|
||||
func (a *baseAuthenticator) GetCookieDomain() string {
|
||||
return a.cookieDomain
|
||||
}
|
||||
|
||||
func (a *baseAuthenticator) GetTokenLifetime() time.Duration {
|
||||
return a.tokenLifetime
|
||||
}
|
||||
43
internal/server/auth/cookies.go
Normal file
43
internal/server/auth/cookies.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type authCookieValues struct {
|
||||
name string
|
||||
value string
|
||||
// domain string
|
||||
httpOnly bool
|
||||
lifetime time.Duration
|
||||
}
|
||||
|
||||
func makeAuthCookie(values authCookieValues) *http.Cookie {
|
||||
expiresAt := time.Now().Add(values.lifetime)
|
||||
|
||||
cookie := &http.Cookie{
|
||||
Name: values.name,
|
||||
Value: values.value,
|
||||
Expires: expiresAt,
|
||||
HttpOnly: values.httpOnly,
|
||||
Path: "/",
|
||||
// Domain: values.domain,
|
||||
MaxAge: int(values.lifetime.Seconds()),
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
return cookie
|
||||
}
|
||||
|
||||
func clearAuthCookie(name string, httpOnly bool, domain string) *http.Cookie {
|
||||
vals := authCookieValues{
|
||||
name: name,
|
||||
value: "",
|
||||
// domain: domain,
|
||||
httpOnly: httpOnly,
|
||||
lifetime: time.Second,
|
||||
}
|
||||
cookie := makeAuthCookie(vals)
|
||||
return cookie
|
||||
}
|
||||
16
internal/server/auth/github.go
Normal file
16
internal/server/auth/github.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package auth
|
||||
|
||||
type GithubAuthenticator struct {
|
||||
baseAuthenticator
|
||||
}
|
||||
|
||||
func NewGithubAuthenticator(cfg Config) (*GithubAuthenticator, error) {
|
||||
ghAuth := &GithubAuthenticator{}
|
||||
// TODO: check these
|
||||
ghAuth.signingKey = cfg.SigningKey
|
||||
ghAuth.tokenLifetime = cfg.TokenLifetime
|
||||
ghAuth.cookieDomain = cfg.CookieDomain
|
||||
ghAuth.authMethod = MethodGithub
|
||||
|
||||
return ghAuth, nil
|
||||
}
|
||||
14
internal/server/auth/none.go
Normal file
14
internal/server/auth/none.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package auth
|
||||
|
||||
type NoneAuthenticator struct {
|
||||
baseAuthenticator
|
||||
}
|
||||
|
||||
func NewNoneAuthenticator(cfg Config) (*NoneAuthenticator, error) {
|
||||
noneAuth := &NoneAuthenticator{}
|
||||
noneAuth.signingKey = cfg.SigningKey
|
||||
noneAuth.tokenLifetime = cfg.TokenLifetime
|
||||
noneAuth.cookieDomain = cfg.CookieDomain
|
||||
noneAuth.authMethod = MethodNone
|
||||
return noneAuth, nil
|
||||
}
|
||||
31
internal/server/auth/password.go
Normal file
31
internal/server/auth/password.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const DefaultBCryptCost = 14
|
||||
|
||||
type PasswordAuthenticator struct {
|
||||
baseAuthenticator
|
||||
bcryptCost int
|
||||
}
|
||||
|
||||
func NewPasswordAuthenticator(cfg Config, bCryptCost int) (*PasswordAuthenticator, error) {
|
||||
pwAuth := &PasswordAuthenticator{}
|
||||
pwAuth.bcryptCost = bCryptCost
|
||||
pwAuth.signingKey = cfg.SigningKey
|
||||
pwAuth.tokenLifetime = cfg.TokenLifetime
|
||||
pwAuth.cookieDomain = cfg.CookieDomain
|
||||
pwAuth.authMethod = MethodPassword
|
||||
|
||||
return pwAuth, nil
|
||||
}
|
||||
|
||||
func (a *PasswordAuthenticator) HashPassword(password []byte) ([]byte, error) {
|
||||
return bcrypt.GenerateFromPassword(password, a.bcryptCost)
|
||||
}
|
||||
|
||||
func (a *PasswordAuthenticator) VerifyHash(password []byte, hash []byte) bool {
|
||||
return bcrypt.CompareHashAndPassword(hash, password) == nil
|
||||
}
|
||||
60
internal/server/bootstrap.go
Normal file
60
internal/server/bootstrap.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"gitlab.com/texm/shokku/internal/models"
|
||||
"gitlab.com/texm/shokku/internal/server/db"
|
||||
)
|
||||
|
||||
func Bootstrap() error {
|
||||
cfg, cfgErr := LoadConfig()
|
||||
if cfgErr != nil {
|
||||
return fmt.Errorf("failed to load server config: %w", cfgErr)
|
||||
}
|
||||
|
||||
var s models.ServerSecrets
|
||||
s.SigningKey = []byte(generateRandomString(32))
|
||||
|
||||
svDb, dbErr := db.Init(cfg.DBPath)
|
||||
if dbErr != nil {
|
||||
return fmt.Errorf("failed to init db: %w", dbErr)
|
||||
}
|
||||
|
||||
deleteErr := svDb.Unscoped().
|
||||
Session(&gorm.Session{AllowGlobalUpdate: true}).
|
||||
Delete(&models.ServerSecrets{}).
|
||||
Error
|
||||
if deleteErr != nil {
|
||||
return fmt.Errorf("failed to delete existing keys: %w", deleteErr)
|
||||
}
|
||||
|
||||
key, genErr := rsa.GenerateKey(rand.Reader, 4096)
|
||||
if genErr != nil {
|
||||
return fmt.Errorf("failed to generate private key: %w", genErr)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if encodeErr := gob.NewEncoder(&buf).Encode(key); encodeErr != nil {
|
||||
return fmt.Errorf("failed to encode priv key: %w", encodeErr)
|
||||
}
|
||||
s.DokkuSSHKeyGob = buf.Bytes()
|
||||
|
||||
publicRsaKey, err := ssh.NewPublicKey(&key.PublicKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if saveErr := svDb.Save(&s).Error; saveErr != nil {
|
||||
return fmt.Errorf("failed to save private key: %w", saveErr)
|
||||
}
|
||||
|
||||
fmt.Printf("%s", bytes.TrimSpace(ssh.MarshalAuthorizedKey(publicRsaKey)))
|
||||
return nil
|
||||
}
|
||||
159
internal/server/commands/commands.go
Normal file
159
internal/server/commands/commands.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"github.com/texm/dokku-go"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type AsyncDokkuCommand func() (*dokku.CommandOutputStream, error)
|
||||
|
||||
const ansiRegexP = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))"
|
||||
|
||||
var (
|
||||
letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
executions = map[string]execution{}
|
||||
statuses = map[string]*ExecutionStatus{}
|
||||
ansiRe = regexp.MustCompile(ansiRegexP)
|
||||
fiveMinutes = time.Minute * 5
|
||||
readTimeout = time.Second * 5
|
||||
|
||||
bufSize = 4096
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotPolled = errors.New("not polled yet")
|
||||
ErrNoExecution = errors.New("no such command id")
|
||||
)
|
||||
|
||||
func sanitiseOutput(b []byte) []byte {
|
||||
b = ansiRe.ReplaceAll(b, []byte{})
|
||||
mapped := bytes.Map(func(r rune) rune {
|
||||
if r > unicode.MaxASCII {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, b)
|
||||
return mapped
|
||||
}
|
||||
|
||||
func readOutput(s chan string, e chan error, r io.Reader) {
|
||||
// defer close(s)
|
||||
// defer close(e)
|
||||
|
||||
output := bytes.Buffer{}
|
||||
reader := bufio.NewReader(r)
|
||||
for {
|
||||
buf := make([]byte, bufSize)
|
||||
n, err := reader.Read(buf)
|
||||
if err != nil {
|
||||
e <- err
|
||||
break
|
||||
}
|
||||
if n > 0 {
|
||||
output.Write(sanitiseOutput(buf[:n]))
|
||||
}
|
||||
if n < bufSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
if output.Len() > 0 {
|
||||
s <- strings.TrimSpace(output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func ReadWithTimeout(reader io.Reader, timeout time.Duration) (string, error) {
|
||||
s := make(chan string)
|
||||
e := make(chan error)
|
||||
|
||||
go readOutput(s, e, reader)
|
||||
|
||||
select {
|
||||
case str := <-s:
|
||||
return str, nil
|
||||
case err := <-e:
|
||||
return "", err
|
||||
case <-time.After(timeout):
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
func toOutputLines(lines []string, outputType string, pollTime time.Time) []dto.OutputLine {
|
||||
output := make([]dto.OutputLine, len(lines))
|
||||
for i, line := range lines {
|
||||
output[i] = dto.OutputLine{Msg: line, Type: outputType, PolledAt: pollTime}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
func getExecutionStatus(id string) *ExecutionStatus {
|
||||
status, ok := statuses[id]
|
||||
if !ok {
|
||||
return &ExecutionStatus{}
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func PollStatuses() {
|
||||
for id, status := range statuses {
|
||||
if !status.Finished {
|
||||
continue
|
||||
}
|
||||
if time.Since(status.FinishedAt) > fiveMinutes {
|
||||
delete(statuses, id)
|
||||
}
|
||||
}
|
||||
|
||||
for id, exec := range executions {
|
||||
if exec.output == nil {
|
||||
continue
|
||||
}
|
||||
status := getExecutionStatus(id)
|
||||
pollTime := time.Now()
|
||||
|
||||
stdout, stdoutErr := ReadWithTimeout(exec.output.Stdout, readTimeout)
|
||||
if stdoutErr != nil {
|
||||
status.StdoutReadError = stdoutErr
|
||||
}
|
||||
if stdout != "" {
|
||||
stdoutLines := strings.Split(stdout, "\n")
|
||||
output := toOutputLines(stdoutLines, "stdout", pollTime)
|
||||
|
||||
// status.Stdout = append(status.Stdout, stdoutLines...)
|
||||
status.CombinedOutput = append(status.CombinedOutput, output...)
|
||||
}
|
||||
|
||||
stderr, stderrErr := ReadWithTimeout(exec.output.Stderr, readTimeout)
|
||||
if stderrErr != nil {
|
||||
status.StderrReadError = stderrErr
|
||||
}
|
||||
if stderr != "" {
|
||||
stderrLines := strings.Split(stderr, "\n")
|
||||
// status.Stderr = append(status.Stderr, stderrLines...)
|
||||
output := toOutputLines(stderrLines, "stderr", pollTime)
|
||||
status.CombinedOutput = append(status.CombinedOutput, output...)
|
||||
}
|
||||
|
||||
status.StreamError = exec.output.Error
|
||||
|
||||
if errors.Is(stdoutErr, io.EOF) {
|
||||
delete(executions, id)
|
||||
status.Finished = true
|
||||
status.FinishedAt = time.Now()
|
||||
if exec.callback != nil {
|
||||
status.CallbackError = exec.callback()
|
||||
}
|
||||
}
|
||||
statuses[id] = status
|
||||
}
|
||||
|
||||
time.Sleep(time.Second)
|
||||
PollStatuses()
|
||||
}
|
||||
72
internal/server/commands/execution.go
Normal file
72
internal/server/commands/execution.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"github.com/texm/dokku-go"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CallbackFunc func() error
|
||||
|
||||
type execution struct {
|
||||
Id string
|
||||
output *dokku.CommandOutputStream
|
||||
callback CallbackFunc
|
||||
error error
|
||||
}
|
||||
|
||||
type ExecutionStatus struct {
|
||||
CombinedOutput []dto.OutputLine
|
||||
|
||||
Finished bool
|
||||
FinishedAt time.Time
|
||||
|
||||
// TODO: this smells
|
||||
CallbackError error
|
||||
StreamError error
|
||||
StdoutReadError error
|
||||
StderrReadError error
|
||||
}
|
||||
|
||||
func generateCommandExecutionId() string {
|
||||
b := make([]rune, 16)
|
||||
for i := range b {
|
||||
b[i] = letterRunes[rand.Intn(len(letterRunes))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func RequestExecution(cmd AsyncDokkuCommand, callback CallbackFunc) string {
|
||||
id := generateCommandExecutionId()
|
||||
|
||||
exec := execution{
|
||||
Id: id,
|
||||
callback: callback,
|
||||
}
|
||||
executions[id] = exec
|
||||
|
||||
go func(exec execution, id string) {
|
||||
stream, err := cmd()
|
||||
exec.output = stream
|
||||
if err != nil {
|
||||
exec.error = err
|
||||
}
|
||||
executions[id] = exec
|
||||
}(exec, id)
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
func GetExecutionStatus(id string) (*ExecutionStatus, error) {
|
||||
status, ok := statuses[id]
|
||||
if !ok {
|
||||
_, exists := executions[id]
|
||||
if exists {
|
||||
return nil, ErrNotPolled
|
||||
}
|
||||
return nil, ErrNoExecution
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
25
internal/server/config.go
Normal file
25
internal/server/config.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/sethvargo/go-envconfig"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DebugMode bool `env:"DEBUG_MODE,default=false"`
|
||||
Host string `env:"HOST,default=0.0.0.0"`
|
||||
Port string `env:"PORT,default=5330"`
|
||||
|
||||
DokkuSSHHost string `env:"DOKKU_SSH_HOST,default=127.0.0.1"`
|
||||
DokkuSSHPort string `env:"DOKKU_SSH_PORT,default=22"`
|
||||
|
||||
DBPath string `env:"DB_PATH,default=/data/shokku.db"`
|
||||
|
||||
AuthTokenLifetimeMinutes int `env:"TOKEN_LIFETIME_MINS,default=15"`
|
||||
}
|
||||
|
||||
func LoadConfig() (Config, error) {
|
||||
ctx := context.Background()
|
||||
var cfg Config
|
||||
return cfg, envconfig.Process(ctx, &cfg)
|
||||
}
|
||||
38
internal/server/db/database.go
Normal file
38
internal/server/db/database.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"github.com/glebarez/sqlite"
|
||||
"gitlab.com/texm/shokku/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func Init(dsn string) (*gorm.DB, error) {
|
||||
dbCfg := &gorm.Config{
|
||||
Logger: Logger{},
|
||||
}
|
||||
|
||||
/*if cfg.DebugMode == false {
|
||||
dbCfg.Logger = dbCfg.Logger.LogMode(logger.Silent)
|
||||
}*/
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(dsn), dbCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = db.AutoMigrate(
|
||||
&models.Server{},
|
||||
&models.ServerSecrets{},
|
||||
&models.App{},
|
||||
&models.Service{},
|
||||
&models.User{},
|
||||
&models.SSHKey{},
|
||||
&models.GithubApp{},
|
||||
&models.AppSetupConfig{},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
70
internal/server/db/logger.go
Normal file
70
internal/server/db/logger.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var durationUnits = map[time.Duration]string{
|
||||
time.Nanosecond: "elapsed_ns",
|
||||
time.Microsecond: "elapsed_us",
|
||||
time.Millisecond: "elapsed_ms",
|
||||
time.Second: "elapsed",
|
||||
time.Minute: "elapsed_min",
|
||||
time.Hour: "elapsed_hr",
|
||||
}
|
||||
|
||||
type Logger struct{}
|
||||
|
||||
func (l Logger) LogMode(logger.LogLevel) logger.Interface {
|
||||
return l
|
||||
}
|
||||
|
||||
func (l Logger) Error(ctx context.Context, msg string, opts ...interface{}) {
|
||||
zerolog.Ctx(ctx).Error().Msg(fmt.Sprintf(msg, opts...))
|
||||
}
|
||||
|
||||
func (l Logger) Warn(ctx context.Context, msg string, opts ...interface{}) {
|
||||
zerolog.Ctx(ctx).Warn().Msg(fmt.Sprintf(msg, opts...))
|
||||
}
|
||||
|
||||
func (l Logger) Info(ctx context.Context, msg string, opts ...interface{}) {
|
||||
zerolog.Ctx(ctx).Info().Msg(fmt.Sprintf(msg, opts...))
|
||||
}
|
||||
|
||||
func (l Logger) Trace(ctx context.Context, begin time.Time, f func() (string, int64), err error) {
|
||||
zl := zerolog.Ctx(ctx)
|
||||
var event *zerolog.Event
|
||||
|
||||
if err != nil {
|
||||
event = zl.Debug()
|
||||
} else {
|
||||
event = zl.Trace()
|
||||
}
|
||||
|
||||
durationKey, found := durationUnits[zerolog.DurationFieldUnit]
|
||||
if !found {
|
||||
zl.Error().
|
||||
Dur("zerolog.DurationFieldUnit", zerolog.DurationFieldUnit).
|
||||
Msg("unknown value for zerolog.DurationFieldUnit")
|
||||
durationKey = "elapsed_"
|
||||
}
|
||||
|
||||
event.Dur(durationKey, time.Since(begin))
|
||||
|
||||
sql, rows := f()
|
||||
if sql != "" {
|
||||
event.Str("sql", sql)
|
||||
}
|
||||
if rows > -1 {
|
||||
event.Int64("rows", rows)
|
||||
}
|
||||
|
||||
event.Send()
|
||||
|
||||
return
|
||||
}
|
||||
28
internal/server/dokku/dokku.go
Normal file
28
internal/server/dokku/dokku.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package dokku
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"github.com/texm/dokku-go"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DebugMode bool
|
||||
|
||||
PrivateKey *rsa.PrivateKey
|
||||
|
||||
Host string
|
||||
Port string
|
||||
}
|
||||
|
||||
func Init(cfg Config) (*dokku.SSHClient, error) {
|
||||
dCfg := &dokku.SSHClientConfig{
|
||||
Host: cfg.Host,
|
||||
Port: cfg.Port,
|
||||
PrivateKey: cfg.PrivateKey,
|
||||
// TODO: supply host key / actually check it
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
}
|
||||
|
||||
return dokku.NewSSHClient(dCfg)
|
||||
}
|
||||
158
internal/server/dokku/sync.go
Normal file
158
internal/server/dokku/sync.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package dokku
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/texm/dokku-go"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/models"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// todo: deduplicate from api/services
|
||||
var (
|
||||
dokkuErrPrefix = "! "
|
||||
serviceTypes = []string{"redis", "postgres", "mysql", "mongo"}
|
||||
)
|
||||
|
||||
const FilteredApp = "shokku"
|
||||
|
||||
func SyncState(e *env.Env) {
|
||||
syncApps(e)
|
||||
syncServices(e)
|
||||
}
|
||||
|
||||
func getServiceList(e *env.Env, serviceType string) ([]string, error) {
|
||||
listCmd := fmt.Sprintf("%s:list --quiet", serviceType)
|
||||
out, err := e.Dokku.Exec(listCmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.HasPrefix(out, dokkuErrPrefix) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return strings.Split(out, "\n"), nil
|
||||
}
|
||||
|
||||
func syncApps(e *env.Env) {
|
||||
logger := log.With().Str("dokku_sync", "apps").Logger()
|
||||
|
||||
apps, listErr := e.Dokku.ListApps()
|
||||
if listErr != nil {
|
||||
if !errors.Is(listErr, dokku.NoDeployedAppsError) {
|
||||
logger.Error().
|
||||
Err(listErr).
|
||||
Msg("Failed to get dokku apps")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var filtered []string
|
||||
for _, name := range apps {
|
||||
if name != FilteredApp {
|
||||
filtered = append(filtered, name)
|
||||
}
|
||||
}
|
||||
|
||||
var dbApps []models.App
|
||||
if dbErr := e.DB.Find(&dbApps).Error; dbErr != nil {
|
||||
logger.Error().
|
||||
Err(dbErr).
|
||||
Msg("Failed to query db apps")
|
||||
return
|
||||
}
|
||||
|
||||
appMap := map[string]bool{}
|
||||
for _, name := range filtered {
|
||||
var dbApp models.App
|
||||
res := e.DB.Limit(1).
|
||||
Where(&models.App{Name: name}).
|
||||
FirstOrCreate(&dbApp)
|
||||
if res.Error != nil {
|
||||
logger.Error().
|
||||
Err(res.Error).
|
||||
Str("app_name", name).
|
||||
Msg("failed to create db app")
|
||||
}
|
||||
|
||||
appMap[name] = true
|
||||
}
|
||||
|
||||
for _, dbApp := range dbApps {
|
||||
status, found := appMap[dbApp.Name]
|
||||
if !found || !status {
|
||||
if err := e.DB.Delete(&dbApp).Error; err != nil {
|
||||
logger.Error().
|
||||
Err(err).
|
||||
Uint("id", dbApp.ID).
|
||||
Str("name", dbApp.Name).
|
||||
Msg("failed to clean old app")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if syncErr := syncApp(e, dbApp); syncErr != nil {
|
||||
logger.Error().
|
||||
Err(syncErr).
|
||||
Str("name", dbApp.Name).
|
||||
Msg("failed to sync dokku app")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func syncApp(e *env.Env, dbApp models.App) error {
|
||||
// TODO: sync setup info
|
||||
return nil
|
||||
}
|
||||
|
||||
func serviceKey(name, svcType string) string {
|
||||
return fmt.Sprintf("%s_%s", svcType, name)
|
||||
}
|
||||
|
||||
func syncServices(e *env.Env) {
|
||||
logger := log.With().Str("dokku_sync", "services").Logger()
|
||||
|
||||
var dbServices []models.Service
|
||||
if dbErr := e.DB.Find(&dbServices).Error; dbErr != nil {
|
||||
logger.Error().
|
||||
Err(dbErr).
|
||||
Msg("Failed to query db services")
|
||||
return
|
||||
}
|
||||
|
||||
svcMap := map[string]bool{}
|
||||
for _, serviceType := range serviceTypes {
|
||||
services, err := getServiceList(e, serviceType)
|
||||
if err != nil {
|
||||
logger.Error().
|
||||
Err(err).
|
||||
Msgf("failed getting %s services", serviceType)
|
||||
continue
|
||||
}
|
||||
for _, name := range services {
|
||||
svcMap[serviceKey(name, serviceType)] = true
|
||||
var dbSvc models.Service
|
||||
e.DB.Where(&models.Service{Name: name, Type: serviceType}).
|
||||
Limit(1).
|
||||
FirstOrCreate(&dbSvc)
|
||||
|
||||
e.DB.Save(&dbSvc)
|
||||
}
|
||||
}
|
||||
|
||||
for _, svc := range dbServices {
|
||||
key := serviceKey(svc.Name, svc.Type)
|
||||
status, found := svcMap[key]
|
||||
if !found || !status {
|
||||
if err := e.DB.Delete(&svc).Error; err != nil {
|
||||
logger.Error().
|
||||
Err(err).
|
||||
Uint("id", svc.ID).
|
||||
Str("type", svc.Type).
|
||||
Str("name", svc.Name).
|
||||
Msg("failed to delete old service")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
325
internal/server/dto/apps.go
Normal file
325
internal/server/dto/apps.go
Normal file
@@ -0,0 +1,325 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"github.com/texm/dokku-go"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GetAppOverviewRequest struct {
|
||||
Name string `query:"name" validate:"appName"`
|
||||
}
|
||||
type GetAppOverviewResponse struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
IsSetup bool `json:"is_setup"`
|
||||
SetupMethod string `json:"setup_method"`
|
||||
GitDeployBranch string `json:"git_deploy_branch"`
|
||||
GitLastUpdated string `json:"git_last_updated"`
|
||||
IsDeployed bool `json:"is_deployed"`
|
||||
IsRunning bool `json:"is_running"`
|
||||
NumProcesses int `json:"num_processes"`
|
||||
CanScale bool `json:"can_scale"`
|
||||
Restore bool `json:"restore"`
|
||||
}
|
||||
|
||||
type GetAllAppsOverviewResponse struct {
|
||||
Apps []GetAppOverviewResponse `json:"apps"`
|
||||
}
|
||||
|
||||
type GetAppsListItem struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
type GetAppsListResponse struct {
|
||||
Apps []GetAppsListItem `json:"apps"`
|
||||
}
|
||||
|
||||
type GetAppInfoRequest struct {
|
||||
Name string `query:"name" validate:"appName"`
|
||||
}
|
||||
|
||||
type DestroyAppRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type AppInfo struct {
|
||||
Name string `json:"name"`
|
||||
Directory string `json:"directory"`
|
||||
DeploySource string `json:"deploy_source"`
|
||||
DeploySourceMetadata string `json:"deploy_source_metadata"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
IsLocked bool `json:"is_locked"`
|
||||
}
|
||||
|
||||
type GetAppInfoResponse struct {
|
||||
Info AppInfo `json:"info"`
|
||||
}
|
||||
|
||||
type ManageAppRequest struct {
|
||||
Name string `json:"name" validate:"appName"`
|
||||
}
|
||||
|
||||
type GetAppSetupStatusRequest struct {
|
||||
Name string `json:"name" validate:"appName"`
|
||||
}
|
||||
type GetAppSetupStatusResponse struct {
|
||||
IsSetup bool `json:"is_setup"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
|
||||
type GetAppSetupConfigRequest struct {
|
||||
Name string `query:"name" validate:"appName"`
|
||||
}
|
||||
type GetAppSetupConfigResponse struct {
|
||||
IsSetup bool `json:"is_setup"`
|
||||
Method string `json:"method"`
|
||||
DeploymentBranch string `json:"deployment_branch,omitempty"`
|
||||
RepoURL string `json:"repo_url,omitempty"`
|
||||
RepoGitRef string `json:"repo_git_ref,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
}
|
||||
|
||||
type SetupAppNewRepoRequest struct {
|
||||
Name string `json:"name" validate:"appName"`
|
||||
DeploymentBranch string `json:"deployment_branch"`
|
||||
}
|
||||
|
||||
type SetupAppSyncRepoRequest struct {
|
||||
Name string `json:"name" validate:"appName"`
|
||||
RepositoryURL string `json:"repository_url"`
|
||||
GitRef string `json:"git_ref"`
|
||||
}
|
||||
|
||||
type SetupAppPullImageRequest struct {
|
||||
Name string `json:"name" validate:"appName"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
type SetupAppUploadArchiveRequest struct {
|
||||
Name string `form:"name" validate:"appName"`
|
||||
}
|
||||
|
||||
type RenameAppRequest struct {
|
||||
CurrentName string `json:"current_name" validate:"appName"`
|
||||
NewName string `json:"new_name" validate:"appName"`
|
||||
}
|
||||
|
||||
/*
|
||||
methods = ["Git Push", "Git Repository", "Archive File", "Dockerfile", "Docker Image"]
|
||||
options = [["deploymentBranch", "envVar"], ["repositoryURL", "gitRef"], ["file"],
|
||||
["dockerfilePath", "usingBuildkit"], ["image"]]
|
||||
*/
|
||||
type DeployAppRequest struct {
|
||||
Name string `json:"name" validate:"appName"`
|
||||
Method string `json:"method" validate:"alpha"`
|
||||
Options map[string]string `json:"options" validate:"alpha"`
|
||||
}
|
||||
|
||||
type GetAppServicesRequest struct {
|
||||
Name string `query:"name" validate:"appName"`
|
||||
}
|
||||
|
||||
type GetAppDeployChecksRequest struct {
|
||||
Name string `query:"name" validate:"appName"`
|
||||
}
|
||||
|
||||
type GetAppDeployChecksResponse struct {
|
||||
AllDisabled bool `json:"all_disabled"`
|
||||
AllSkipped bool `json:"all_skipped"`
|
||||
DisabledProcesses []string `json:"disabled_processes"`
|
||||
SkippedProcesses []string `json:"skipped_processes"`
|
||||
}
|
||||
|
||||
type SetAppDeployChecksRequest struct {
|
||||
Name string `json:"name" validate:"appName"`
|
||||
// enabled, disabled, skipped
|
||||
State string `json:"state" validate:"alpha"`
|
||||
}
|
||||
|
||||
type SetAppProcessDeployChecksRequest struct {
|
||||
Name string `json:"name" validate:"appName"`
|
||||
Process string `json:"process" validate:"processName"`
|
||||
// enabled, disabled, skipped
|
||||
State string `json:"state" validate:"alpha"`
|
||||
}
|
||||
|
||||
type GetAppProcessesRequest struct {
|
||||
Name string `query:"name" validate:"appName"`
|
||||
}
|
||||
|
||||
type GetAppProcessesResponse struct {
|
||||
Processes []string `json:"processes"`
|
||||
}
|
||||
|
||||
type GetAppProcessReportRequest struct {
|
||||
Name string `query:"name" validate:"appName"`
|
||||
}
|
||||
|
||||
type AppProcessInfo struct {
|
||||
Scale int `json:"scale"`
|
||||
Resources dokku.ResourceSettings `json:"resources"`
|
||||
}
|
||||
|
||||
type GetAppProcessReportResponse struct {
|
||||
ResourceDefaults dokku.ResourceSettings `json:"resource_defaults"`
|
||||
Processes map[string]AppProcessInfo `json:"processes"`
|
||||
}
|
||||
|
||||
type AppResources struct {
|
||||
CPU *int `json:"cpu"`
|
||||
Memory *int `json:"memory"`
|
||||
MemoryUnit *string `json:"memory_unit"`
|
||||
}
|
||||
|
||||
type SetAppProcessResourcesRequest struct {
|
||||
Name string `json:"name" validate:"appName"`
|
||||
Process string `json:"process" validate:"processName"`
|
||||
ResourceLimits AppResources `json:"limits"`
|
||||
ResourceReservations AppResources `json:"reservations"`
|
||||
}
|
||||
|
||||
type GetAppProcessScaleRequest struct {
|
||||
Name string `query:"name" validate:"appName"`
|
||||
}
|
||||
type GetAppProcessScaleResponse struct {
|
||||
ProcessScale map[string]int `json:"process_scale"`
|
||||
}
|
||||
|
||||
type SetAppProcessScaleRequest struct {
|
||||
Name string `json:"name" validate:"appName"`
|
||||
Process string `json:"process" validate:"processName"`
|
||||
Scale int `json:"scale" validate:"numeric"`
|
||||
SkipDeploy bool `json:"skip_deploy"`
|
||||
}
|
||||
|
||||
type GetAppDomainsReportRequest struct {
|
||||
Name string `query:"name" validate:"appName"`
|
||||
}
|
||||
|
||||
type GetAppDomainsReportResponse struct {
|
||||
Domains []string `json:"domains"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type SetAppDomainsEnabledRequest struct {
|
||||
Name string `json:"name" validate:"appName"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type GetAppLetsEncryptEnabledRequest struct {
|
||||
Name string `query:"name" validate:"appName"`
|
||||
}
|
||||
type SetAppLetsEncryptEnabledRequest struct {
|
||||
Name string `json:"name" validate:"appName"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type GetAppDomainsRequest struct {
|
||||
Name string `query:"name" validate:"appName"`
|
||||
}
|
||||
type AlterAppDomainRequest struct {
|
||||
Name string `json:"name" validate:"appName"`
|
||||
Domain string `json:"domain" validate:"hostname_rfc1123"`
|
||||
}
|
||||
|
||||
type AlterNetworkRequest struct {
|
||||
Network string `query:"network"`
|
||||
}
|
||||
|
||||
type ListNetworksResponse struct {
|
||||
Networks []string `json:"networks"`
|
||||
}
|
||||
|
||||
type GetAppNetworksReportRequest struct {
|
||||
Name string `query:"name" validate:"appName"`
|
||||
}
|
||||
type GetAppNetworksReportResponse struct {
|
||||
AttachInitial string `json:"attach_initial"`
|
||||
AttachPostCreate string `json:"attach_post_create"`
|
||||
AttachPostDeploy string `json:"attach_post_deploy"`
|
||||
BindAllInterfaces bool `json:"bind_all_interfaces"`
|
||||
TLD string `json:"tld"`
|
||||
WebListeners string `json:"web_listeners"`
|
||||
}
|
||||
|
||||
type SetAppNetworksRequest struct {
|
||||
Name string `query:"name" validate:"appName"`
|
||||
|
||||
Initial *string `json:"attach_initial"`
|
||||
PostCreate *string `json:"attach_post_create"`
|
||||
PostDeploy *string `json:"attach_post_deploy"`
|
||||
BindAllInterfaces *bool `json:"bind_all_interfaces"`
|
||||
TLD *string `json:"tld"`
|
||||
}
|
||||
|
||||
type GetAppLogsRequest struct {
|
||||
Name string `query:"name" validate:"appName"`
|
||||
}
|
||||
type GetAppLogsResponse struct {
|
||||
Logs []string `json:"logs"`
|
||||
}
|
||||
|
||||
type GetAppConfigRequest struct {
|
||||
Name string `query:"name" validate:"appName"`
|
||||
}
|
||||
type GetAppConfigResponse struct {
|
||||
Config map[string]string `json:"config"`
|
||||
}
|
||||
|
||||
type SetAppConfigRequest struct {
|
||||
Name string `json:"name" validate:"appName"`
|
||||
// validate keys and values are alphanumeric
|
||||
Config map[string]string `json:"config" validate:"dive,keys,alphanum,endkeys,alphanum"`
|
||||
// Config map[string]string `json:"config"`
|
||||
}
|
||||
|
||||
type GetAppStorageRequest struct {
|
||||
Name string `query:"name" validate:"appName"`
|
||||
}
|
||||
type StorageMount struct {
|
||||
HostDir string `json:"hostDir"`
|
||||
ContainerDir string `json:"mountDir"`
|
||||
IsBuildMount bool `json:"isBuildMount"`
|
||||
IsRunMount bool `json:"isRunMount"`
|
||||
IsDeployMount bool `json:"isDeployMount"`
|
||||
}
|
||||
type GetAppStorageResponse struct {
|
||||
Mounts []StorageMount `json:"mounts"`
|
||||
}
|
||||
|
||||
type AlterAppStorageRequest struct {
|
||||
Name string `json:"name" validate:"appName"`
|
||||
RestartApp bool `json:"restart"`
|
||||
|
||||
// TODO: validation for these
|
||||
StorageType string `json:"selectedType"`
|
||||
HostDir string `json:"hostDir" validate:"gte=2,alphanum"`
|
||||
ContainerDir string `json:"mountDir"`
|
||||
}
|
||||
|
||||
type GetAppBuilderRequest struct {
|
||||
Name string `query:"name" validate:"appName"`
|
||||
}
|
||||
type GetAppBuilderResponse struct {
|
||||
Selected string `json:"selected"`
|
||||
}
|
||||
|
||||
type SetAppBuilderRequest struct {
|
||||
Name string `json:"name" validate:"appName"`
|
||||
Builder string `json:"builder" validate:"alphanum"`
|
||||
}
|
||||
|
||||
type GetAppBuildDirectoryRequest struct {
|
||||
Name string `query:"name" validate:"appName"`
|
||||
}
|
||||
type GetAppBuildDirectoryResponse struct {
|
||||
Directory string `json:"directory"`
|
||||
}
|
||||
|
||||
type SetAppBuildDirectoryRequest struct {
|
||||
Name string `json:"name" validate:"appName"`
|
||||
Directory string `json:"directory" validate:"alphanum"`
|
||||
}
|
||||
|
||||
type ClearAppBuildDirectoryRequest struct {
|
||||
Name string `query:"name" validate:"appName"`
|
||||
}
|
||||
65
internal/server/dto/auth.go
Normal file
65
internal/server/dto/auth.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package dto
|
||||
|
||||
type PasswordLoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
TotpCode string `json:"totp"`
|
||||
}
|
||||
|
||||
type PasswordLoginResponse struct {
|
||||
Success bool `json:"success"`
|
||||
NeedsTotp bool `json:"needs_totp"`
|
||||
}
|
||||
|
||||
type GithubAuthRequest struct {
|
||||
Code string `json:"code"`
|
||||
RedirectURL string `json:"redirect_url"`
|
||||
}
|
||||
|
||||
type GetGithubSetupStatus struct {
|
||||
AppCreated bool `json:"created"`
|
||||
}
|
||||
|
||||
type CreateGithubAppRequest struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type CreateGithubAppResponse struct {
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
|
||||
type InstallGithubAppResponse struct {
|
||||
InstallURL string `json:"install_url"`
|
||||
}
|
||||
|
||||
type CompleteGithubSetupRequest struct {
|
||||
Code string `json:"code"`
|
||||
InstallationId int64 `json:"installation_id"`
|
||||
}
|
||||
|
||||
type GetGithubAuthInfoResponse struct {
|
||||
ClientID string `json:"client_id"`
|
||||
}
|
||||
|
||||
type GenerateTotpResponse struct {
|
||||
Secret string `json:"secret"`
|
||||
Image string `json:"image"`
|
||||
RecoveryCode string `json:"recovery_code"`
|
||||
}
|
||||
|
||||
type ConfirmTotpRequest struct {
|
||||
Secret string `json:"secret"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type ConfirmTotpResponse struct {
|
||||
Valid bool `json:"valid"`
|
||||
}
|
||||
|
||||
type CompletePasswordSetupRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Enable2FA bool `json:"enable_2fa"`
|
||||
TotpSecret string `json:"totp_secret"`
|
||||
RecoveryCode string `json:"recovery_code"`
|
||||
}
|
||||
38
internal/server/dto/command.go
Normal file
38
internal/server/dto/command.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type GetCommandExecutionStatusRequest struct {
|
||||
ExecutionID string `query:"execution_id" validate:"required"`
|
||||
}
|
||||
|
||||
type CommandExecutionResponse struct {
|
||||
ExecutionID string `json:"execution_id"`
|
||||
}
|
||||
|
||||
type OutputLine struct {
|
||||
Msg string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
PolledAt time.Time `json:"polled_at"`
|
||||
}
|
||||
|
||||
type CommandExecutionStatusResponse struct {
|
||||
Started bool `json:"started"`
|
||||
Finished bool `json:"finished"`
|
||||
Success bool `json:"success"`
|
||||
|
||||
CombinedOutput []OutputLine `json:"output"`
|
||||
}
|
||||
|
||||
type AppExecInProcessRequest struct {
|
||||
AppName string `json:"appName" validate:"appName"`
|
||||
ProcessName string `json:"processName" validate:"required"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
type AppExecInProcessResponse struct {
|
||||
Output string `json:"output"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
117
internal/server/dto/dto.go
Normal file
117
internal/server/dto/dto.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/texm/dokku-go"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// allow alphanumeric, underscores, and hyphens
|
||||
func appNameCharsValidator() func(level validator.FieldLevel) bool {
|
||||
r, err := regexp.Compile("\\w[\\w\\-]*")
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed to compile regexp")
|
||||
}
|
||||
return func(fl validator.FieldLevel) bool {
|
||||
if fl.Field().Kind() != reflect.String {
|
||||
return false
|
||||
}
|
||||
str := fl.Field().String()
|
||||
return r.FindString(str) == str
|
||||
}
|
||||
}
|
||||
|
||||
type requestValidator struct {
|
||||
validator *validator.Validate
|
||||
}
|
||||
|
||||
func (rv *requestValidator) Validate(i interface{}) error {
|
||||
return rv.validator.Struct(i)
|
||||
}
|
||||
|
||||
func NewRequestValidator() *requestValidator {
|
||||
v := validator.New()
|
||||
|
||||
err := v.RegisterValidation("appNameChars", appNameCharsValidator())
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed to register validator")
|
||||
}
|
||||
v.RegisterAlias("appName", "appNameChars,min=4,max=16")
|
||||
v.RegisterAlias("processName", "appNameChars,min=2,max=16")
|
||||
|
||||
return &requestValidator{validator: v}
|
||||
}
|
||||
|
||||
type RequestError struct {
|
||||
err error
|
||||
isBinding bool
|
||||
isInvalidFormat bool
|
||||
isInvalidData bool
|
||||
validationErrors []validator.FieldError
|
||||
}
|
||||
|
||||
func (r *RequestError) String() string {
|
||||
return fmt.Sprintf("%+v", r.validationErrors)
|
||||
}
|
||||
|
||||
func (r *RequestError) ToHTTP() *echo.HTTPError {
|
||||
err := echo.NewHTTPError(http.StatusBadRequest).SetInternal(r.err)
|
||||
|
||||
if r.isBinding {
|
||||
err.Message = echo.Map{"type": "binding"}
|
||||
} else if r.isInvalidFormat {
|
||||
err.Message = echo.Map{"type": "format"}
|
||||
} else if r.isInvalidData {
|
||||
fields := map[string]string{}
|
||||
for _, fe := range r.validationErrors {
|
||||
fields[fe.Field()] = fe.ActualTag()
|
||||
}
|
||||
err.Message = echo.Map{
|
||||
"type": "validation",
|
||||
"fields": fields,
|
||||
}
|
||||
} else {
|
||||
err = echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func BindRequest(c echo.Context, r any) *RequestError {
|
||||
if err := c.Bind(r); err != nil {
|
||||
return &RequestError{
|
||||
err: err,
|
||||
isBinding: true,
|
||||
}
|
||||
}
|
||||
if err := c.Validate(r); err != nil {
|
||||
if errors, ok := err.(validator.ValidationErrors); ok {
|
||||
return &RequestError{
|
||||
err: err,
|
||||
isInvalidData: true,
|
||||
validationErrors: errors,
|
||||
}
|
||||
}
|
||||
return &RequestError{
|
||||
err: err,
|
||||
isInvalidFormat: true,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func MaybeConvertDokkuError(err error) *echo.HTTPError {
|
||||
if errors.Is(err, dokku.InvalidAppError) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "no such app")
|
||||
}
|
||||
if errors.Is(err, dokku.NameTakenError) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "duplicate app name")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
114
internal/server/dto/services.go
Normal file
114
internal/server/dto/services.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package dto
|
||||
|
||||
type ManageServiceRequest struct {
|
||||
Name string `json:"name" validate:"appName"`
|
||||
Type string `json:"type" validate:"alphanum"`
|
||||
}
|
||||
|
||||
type GenericServiceRequest struct {
|
||||
Name string `query:"name" validate:"appName"`
|
||||
Type string `query:"type" validate:"alphanum"`
|
||||
}
|
||||
|
||||
type GenericServiceCreationConfig struct {
|
||||
ConfigOptions *string `json:"config-options"`
|
||||
// validate inner pairs are len=2
|
||||
CustomEnv *[][]string `json:"custom-env"`
|
||||
Image *string `json:"image"`
|
||||
ImageVersion *string `json:"image-version"`
|
||||
MemoryLimit *string `json:"memory"`
|
||||
Password *string `json:"password"`
|
||||
RootPassword *string `json:"root-password"`
|
||||
SharedMemorySize *string `json:"shm-size"`
|
||||
}
|
||||
|
||||
type CreateGenericServiceRequest struct {
|
||||
Name string `json:"name" validate:"appName"`
|
||||
ServiceType string `json:"type"`
|
||||
Config GenericServiceCreationConfig `json:"config"`
|
||||
}
|
||||
|
||||
type CloneServiceRequest struct {
|
||||
Name string `json:"name" validate:"appName"`
|
||||
NewName string `json:"newName" validate:"appName"`
|
||||
}
|
||||
|
||||
type ServiceInfo struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type ListServicesResponse struct {
|
||||
Services []ServiceInfo `json:"services"`
|
||||
}
|
||||
|
||||
type GetServiceInfoResponse struct {
|
||||
Info map[string]string `json:"info"`
|
||||
}
|
||||
|
||||
type GetServiceTypeRequest struct {
|
||||
Name string `query:"name" validate:"appName"`
|
||||
}
|
||||
type GetServiceTypeResponse struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type LinkGenericServiceToAppRequest struct {
|
||||
ServiceName string `json:"service_name" validate:"appName"`
|
||||
AppName string `json:"app_name" validate:"appName"`
|
||||
Alias string `json:"alias"`
|
||||
QueryString string `json:"query_string"`
|
||||
}
|
||||
|
||||
type GetServiceLinkedAppsResponse struct {
|
||||
Apps []string `json:"apps"`
|
||||
}
|
||||
|
||||
type GetServiceLogsResponse struct {
|
||||
Logs []string `json:"logs"`
|
||||
}
|
||||
|
||||
type GetServiceBackupReportRequest struct {
|
||||
Name string `query:"name" validate:"appName"`
|
||||
}
|
||||
|
||||
type ServiceBackupReport struct {
|
||||
AuthSet bool `json:"auth_set"`
|
||||
EncryptionSet bool `json:"encryption_set"`
|
||||
Bucket string `json:"bucket"`
|
||||
Schedule string `json:"schedule"`
|
||||
}
|
||||
type GetServiceBackupReportResponse struct {
|
||||
Report ServiceBackupReport `json:"report"`
|
||||
}
|
||||
|
||||
type RunServiceBackupRequest struct {
|
||||
Name string `query:"name" validate:"appName"`
|
||||
}
|
||||
|
||||
type BackupsAuthConfig struct {
|
||||
AccessKeyId string `json:"access_key_id"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Region string `json:"region"`
|
||||
SignatureVersion string `json:"signature_version"`
|
||||
EndpointUrl string `json:"endpoint_url"`
|
||||
}
|
||||
type SetServiceBackupsAuthRequest struct {
|
||||
Name string `json:"name" validate:"appName"`
|
||||
Config BackupsAuthConfig `json:"config"`
|
||||
}
|
||||
|
||||
type SetServiceBackupsBucketRequest struct {
|
||||
Name string `json:"name" validate:"appName"`
|
||||
Bucket string `json:"bucket"`
|
||||
}
|
||||
|
||||
type SetServiceBackupsScheduleRequest struct {
|
||||
Name string `json:"name" validate:"appName"`
|
||||
Schedule string `json:"schedule"`
|
||||
}
|
||||
|
||||
type SetServiceBackupsEncryptionRequest struct {
|
||||
Name string `json:"name" validate:"appName"`
|
||||
Passphrase string `json:"passphrase"`
|
||||
}
|
||||
78
internal/server/dto/settings.go
Normal file
78
internal/server/dto/settings.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package dto
|
||||
|
||||
import "github.com/texm/dokku-go"
|
||||
|
||||
type GetVersionsResponse struct {
|
||||
Dokku string `json:"dokku"`
|
||||
Shokku string `json:"shokku"`
|
||||
}
|
||||
|
||||
type GetLetsEncryptStatusResponse struct {
|
||||
Installed bool `json:"installed"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
SSHKeys []string `json:"ssh_keys"`
|
||||
}
|
||||
type GetUsersResponse struct {
|
||||
Users []User `json:"users"`
|
||||
}
|
||||
|
||||
type GetSSHKeysResponse struct {
|
||||
Keys []dokku.SSHKey `json:"keys"`
|
||||
}
|
||||
|
||||
type GetGlobalDomainsResponse struct {
|
||||
Domains []string `json:"domains"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
type AlterGlobalDomainRequest struct {
|
||||
Domain string `json:"domain"`
|
||||
}
|
||||
type DeleteGlobalDomainRequest struct {
|
||||
Domain string `query:"domain"`
|
||||
}
|
||||
|
||||
type AddGitAuthRequest struct {
|
||||
Host string `json:"host"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type RemoveGitAuthRequest struct {
|
||||
Host string `json:"host"`
|
||||
}
|
||||
|
||||
type SetDockerRegistryRequest struct {
|
||||
Server string `json:"server"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type GetDockerRegistryReportResponse struct {
|
||||
Server string `json:"server"`
|
||||
PushOnRelease bool `json:"push_on_release"`
|
||||
}
|
||||
|
||||
type SetEventLoggingEnabledRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
type GetEventLogsListResponse struct {
|
||||
Events []string `json:"events"`
|
||||
}
|
||||
type GetEventLogsResponse struct {
|
||||
Logs string `json:"logs"`
|
||||
}
|
||||
|
||||
type PluginInfo struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
type ListPluginsResponse struct {
|
||||
Plugins []PluginInfo `json:"plugins"`
|
||||
}
|
||||
6
internal/server/dto/setup.go
Normal file
6
internal/server/dto/setup.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package dto
|
||||
|
||||
type GetSetupStatusResponse struct {
|
||||
IsSetup bool `json:"is_setup"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
86
internal/server/github/app.go
Normal file
86
internal/server/github/app.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/bradleyfalzon/ghinstallation/v2"
|
||||
gh "github.com/google/go-github/v48/github"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/models"
|
||||
)
|
||||
|
||||
type AppClient struct {
|
||||
*gh.Client
|
||||
appsTransport *ghinstallation.AppsTransport
|
||||
}
|
||||
|
||||
func GetAppClient(e *env.Env) (*AppClient, error) {
|
||||
var dbApp models.GithubApp
|
||||
if err := e.DB.Find(&dbApp).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transport, err := ghinstallation.NewAppsTransport(
|
||||
http.DefaultTransport, dbApp.AppId, []byte(dbApp.PEM))
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("failed to create transport")
|
||||
return nil, err
|
||||
}
|
||||
appClient := &AppClient{
|
||||
Client: gh.NewClient(&http.Client{Transport: transport}),
|
||||
appsTransport: transport,
|
||||
}
|
||||
return appClient, nil
|
||||
}
|
||||
|
||||
type AppInstallationClient struct {
|
||||
*gh.Client
|
||||
}
|
||||
|
||||
func (c *AppClient) GetInstallationClient(id int64) *AppInstallationClient {
|
||||
transport := ghinstallation.NewFromAppsTransport(c.appsTransport, id)
|
||||
client := gh.NewClient(&http.Client{Transport: transport})
|
||||
return &AppInstallationClient{Client: client}
|
||||
}
|
||||
|
||||
func CompleteAppManifest(e *env.Env, code string) (*gh.AppConfig, error) {
|
||||
ctx := context.Background()
|
||||
client := gh.NewClient(nil)
|
||||
cfg, _, ghErr := client.Apps.CompleteAppManifest(ctx, code)
|
||||
if ghErr != nil {
|
||||
return nil, ghErr
|
||||
}
|
||||
|
||||
appId := cfg.GetID()
|
||||
dbApp := models.GithubApp{AppId: appId}
|
||||
if dbErr := e.DB.FirstOrCreate(&dbApp).Error; dbErr != nil {
|
||||
log.Error().Err(dbErr).Msg("failed db lookup")
|
||||
return nil, dbErr
|
||||
}
|
||||
|
||||
dbApp.AppId = appId
|
||||
dbApp.ClientId = cfg.GetClientID()
|
||||
dbApp.NodeId = cfg.GetNodeID()
|
||||
dbApp.Slug = cfg.GetSlug()
|
||||
dbApp.PEM = cfg.GetPEM()
|
||||
dbApp.ClientSecret = cfg.GetClientSecret()
|
||||
dbApp.WebhookSecret = cfg.GetWebhookSecret()
|
||||
|
||||
// saveRes := e.DB.Where(&models.GithubApp{AppId: appId}).Save(&dbApp)
|
||||
if err := e.DB.Save(&dbApp).Error; err != nil {
|
||||
log.Error().Err(err).Msg("failed to save db app")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c *AppClient) GetApp(ctx context.Context) (*gh.App, error) {
|
||||
app, _, err := c.Apps.Get(ctx, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return app, nil
|
||||
}
|
||||
148
internal/server/github/sync.go
Normal file
148
internal/server/github/sync.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
gh "github.com/google/go-github/v48/github"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/models"
|
||||
"gorm.io/gorm/clause"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type githubUser struct {
|
||||
User models.User
|
||||
SSHKeys []models.SSHKey
|
||||
}
|
||||
|
||||
func Sync(e *env.Env) error {
|
||||
if err := SyncUsersToDB(e); err != nil {
|
||||
return fmt.Errorf("failed to sync github users: %w", err)
|
||||
}
|
||||
|
||||
//if err := SyncInstallationStatus(e); err != nil {
|
||||
// return fmt.Errorf()
|
||||
//}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncUsersToDB asynchronously get users in organization & add to db
|
||||
func SyncUsersToDB(e *env.Env) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||
defer cancel()
|
||||
|
||||
client, clientErr := GetAppClient(e)
|
||||
if clientErr != nil {
|
||||
return fmt.Errorf("failed to get app client: %w", clientErr)
|
||||
}
|
||||
|
||||
installs, _, installsErr := client.Apps.ListInstallations(ctx, nil)
|
||||
if installsErr != nil {
|
||||
return installsErr
|
||||
}
|
||||
|
||||
var users []githubUser
|
||||
for _, install := range installs {
|
||||
var members []*gh.User
|
||||
var err error
|
||||
if install.GetAccount().GetType() == "Organization" {
|
||||
insClient := client.GetInstallationClient(install.GetID())
|
||||
org := install.GetAccount().GetLogin()
|
||||
members, _, err = insClient.Organizations.ListMembers(ctx, org, nil)
|
||||
} else {
|
||||
members = append(members, install.GetAccount())
|
||||
}
|
||||
if err != nil {
|
||||
log.Error().Err(err).
|
||||
Int64("installation_id", install.GetID()).
|
||||
Msg("failed to get members")
|
||||
continue
|
||||
}
|
||||
for _, member := range members {
|
||||
users = append(users, fetchUserInfo(member))
|
||||
}
|
||||
}
|
||||
|
||||
conflict := clause.OnConflict{
|
||||
DoUpdates: clause.AssignmentColumns([]string{"updated_at"}),
|
||||
}
|
||||
|
||||
for _, u := range users {
|
||||
if err := e.DB.Clauses(conflict).Create(&u.User).Error; err != nil {
|
||||
log.Error().Err(err).
|
||||
Str("name", u.User.Name).
|
||||
Msg("failed to create user")
|
||||
continue
|
||||
}
|
||||
for _, key := range u.SSHKeys {
|
||||
key.UserID = u.User.ID
|
||||
if err := e.DB.Clauses(conflict).Create(&key).Error; err != nil {
|
||||
log.Error().Err(err).
|
||||
Str("name", u.User.Name).
|
||||
Msg("failed to create user ssh key")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
oneMinuteAgo := time.Now().Add(-time.Minute)
|
||||
var deletedUsers []models.User
|
||||
rUsers := e.DB.Where("updated_at < ?", oneMinuteAgo).Delete(&deletedUsers)
|
||||
if rUsers.Error != nil {
|
||||
log.Error().Err(rUsers.Error).Msg("failed to delete old users")
|
||||
}
|
||||
|
||||
var deletedKeys []models.SSHKey
|
||||
rKeys := e.DB.Where("updated_at < ?", oneMinuteAgo).Delete(&deletedKeys)
|
||||
if rKeys.Error != nil {
|
||||
log.Error().Err(rKeys.Error).Msg("failed to delete old ssh keys")
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Int("num_installations", len(installs)).
|
||||
Int("synced_users", len(users)).
|
||||
Int64("removed_users", rUsers.RowsAffected).
|
||||
Int64("removed_keys", rKeys.RowsAffected).
|
||||
Msgf("github user sync complete")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchUserInfo(u *gh.User) githubUser {
|
||||
username := u.GetLogin()
|
||||
user := githubUser{
|
||||
User: models.User{Name: username, Source: "github"},
|
||||
}
|
||||
userKeysApi := fmt.Sprintf("https://api.github.com/users/%s/keys", username)
|
||||
res, reqErr := http.Get(userKeysApi)
|
||||
if reqErr != nil {
|
||||
log.Error().Err(reqErr).
|
||||
Str("username", username).
|
||||
Msg("failed to get users SSH keys")
|
||||
return user
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to read response body")
|
||||
return user
|
||||
}
|
||||
|
||||
var keys []gh.Key
|
||||
if err := json.Unmarshal(body, &keys); err != nil {
|
||||
log.Error().Err(err).Msg("failed to unmarshal keys")
|
||||
return user
|
||||
}
|
||||
|
||||
user.SSHKeys = make([]models.SSHKey, len(keys))
|
||||
for i, key := range keys {
|
||||
user.SSHKeys[i] = models.SSHKey{
|
||||
GithubID: key.GetID(),
|
||||
Key: key.GetKey(),
|
||||
}
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
56
internal/server/github/user.go
Normal file
56
internal/server/github/user.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
gh "github.com/google/go-github/v48/github"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/models"
|
||||
"golang.org/x/oauth2"
|
||||
ghOAuth "golang.org/x/oauth2/github"
|
||||
)
|
||||
|
||||
type UserClient struct {
|
||||
client *gh.Client
|
||||
}
|
||||
|
||||
type CodeExchangeParams struct {
|
||||
Code string
|
||||
Scopes []string
|
||||
RedirectURL string
|
||||
}
|
||||
|
||||
func ExchangeCode(ctx context.Context, e *env.Env, p CodeExchangeParams) (*UserClient, error) {
|
||||
var ghApp models.GithubApp
|
||||
if err := e.DB.WithContext(ctx).First(&ghApp).Error; err != nil {
|
||||
log.Error().Err(err).Msg("no github app in db")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conf := &oauth2.Config{
|
||||
ClientID: ghApp.ClientId,
|
||||
ClientSecret: ghApp.ClientSecret,
|
||||
Scopes: p.Scopes,
|
||||
RedirectURL: p.RedirectURL,
|
||||
Endpoint: ghOAuth.Endpoint,
|
||||
}
|
||||
token, err := conf.Exchange(ctx, p.Code)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to exchange code for token")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tokenSource := oauth2.StaticTokenSource(token)
|
||||
oauthClient := oauth2.NewClient(ctx, tokenSource)
|
||||
client := gh.NewClient(oauthClient)
|
||||
return &UserClient{client}, nil
|
||||
}
|
||||
|
||||
func (u *UserClient) GetUser(ctx context.Context) (*gh.User, error) {
|
||||
user, _, err := u.client.Users.Get(ctx, "")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to get user")
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
75
internal/server/middleware/auth.go
Normal file
75
internal/server/middleware/auth.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
echoMiddleware "github.com/labstack/echo/v4/middleware"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/server/auth"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
tokenContextKey = "user-token"
|
||||
usedCookieAuthContextKey = "cookie-auth"
|
||||
)
|
||||
|
||||
func skipDuringSetup(e *env.Env, c echo.Context) bool {
|
||||
reqPath := c.Request().URL.Path
|
||||
return !e.SetupCompleted && strings.HasPrefix(reqPath, "/api/setup")
|
||||
}
|
||||
|
||||
func ProvideUserContext(e *env.Env) echo.MiddlewareFunc {
|
||||
logger := middlewareLogger("userContext")
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if skipDuringSetup(e, c) {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
if err := e.Auth.SetUserContext(c, tokenContextKey); err != nil {
|
||||
logger.Error().Err(err).Msg("failed to set context")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tokenAuthSkipper(e *env.Env) echoMiddleware.Skipper {
|
||||
return func(c echo.Context) bool {
|
||||
return skipDuringSetup(e, c)
|
||||
}
|
||||
}
|
||||
|
||||
func TokenAuth(e *env.Env) echo.MiddlewareFunc {
|
||||
config := echoMiddleware.JWTConfig{
|
||||
Claims: &auth.User{},
|
||||
SigningKey: e.Auth.GetSigningKey(),
|
||||
TokenLookupFuncs: []echoMiddleware.ValuesExtractor{SplitTokenLookup},
|
||||
TokenLookup: "header:Authorization",
|
||||
ContextKey: tokenContextKey,
|
||||
Skipper: tokenAuthSkipper(e),
|
||||
}
|
||||
return echoMiddleware.JWTWithConfig(config)
|
||||
}
|
||||
|
||||
func SplitTokenLookup(c echo.Context) ([]string, error) {
|
||||
dataCookie, err := c.Request().Cookie(auth.DataCookieName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no data cookie: %w", err)
|
||||
}
|
||||
signatureCookie, err := c.Request().Cookie(auth.SignatureCookieName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no signature cookie: %w", err)
|
||||
}
|
||||
|
||||
c.Set(usedCookieAuthContextKey, true)
|
||||
authToken := dataCookie.Value + "." + signatureCookie.Value
|
||||
return []string{authToken}, nil
|
||||
}
|
||||
|
||||
func CheckCookieAuthUsed(c echo.Context) bool {
|
||||
v, ok := c.Get(usedCookieAuthContextKey).(bool)
|
||||
return ok && v
|
||||
}
|
||||
10
internal/server/middleware/middleware.go
Normal file
10
internal/server/middleware/middleware.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func middlewareLogger(ware string) zerolog.Logger {
|
||||
return log.With().Str("middleware", ware).Logger()
|
||||
}
|
||||
31
internal/server/middleware/recover.go
Normal file
31
internal/server/middleware/recover.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
echoMiddleware "github.com/labstack/echo/v4/middleware"
|
||||
"github.com/rs/zerolog"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func logErrorFunc(logger zerolog.Logger, debug bool) echoMiddleware.LogErrorFunc {
|
||||
return func(c echo.Context, err error, stack []byte) error {
|
||||
event := logger.Error().Err(err)
|
||||
if debug {
|
||||
stacklines := strings.Split(string(stack), "\n")
|
||||
funcName := strings.TrimRight(strings.SplitAfter(stacklines[5], "(0x")[0], "(0x")
|
||||
callSite := strings.Trim(strings.SplitAfter(stacklines[6], " ")[0], "\t ")
|
||||
event.Str("func", funcName)
|
||||
event.Str("callsite", callSite)
|
||||
}
|
||||
event.Msg("Recovered from panic")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func Recover(debug bool) echo.MiddlewareFunc {
|
||||
logger := middlewareLogger("recover")
|
||||
cfg := echoMiddleware.RecoverConfig{
|
||||
LogErrorFunc: logErrorFunc(logger, debug),
|
||||
}
|
||||
return echoMiddleware.RecoverWithConfig(cfg)
|
||||
}
|
||||
87
internal/server/middleware/request_logger.go
Normal file
87
internal/server/middleware/request_logger.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func parseDokkuError(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
err := next(c)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if dokkuHttpErr := dto.MaybeConvertDokkuError(err); dokkuHttpErr != nil {
|
||||
log.Debug().Err(err).Msgf("converted dokku error to %s", dokkuHttpErr.Error())
|
||||
return dokkuHttpErr
|
||||
}
|
||||
|
||||
log.Error().Err(err).Str("path", c.Path()).Msg("got error")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
func RequestLogger(debug bool) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
start := time.Now()
|
||||
|
||||
chainErr := next(c)
|
||||
if chainErr != nil {
|
||||
c.Error(chainErr)
|
||||
}
|
||||
|
||||
req := c.Request()
|
||||
res := c.Response()
|
||||
|
||||
if isStaticFile, ok := c.Get("static").(bool); ok && isStaticFile {
|
||||
contentType := res.Header().Get("Content-Type")
|
||||
isHTML := strings.HasPrefix(contentType, echo.MIMETextHTML)
|
||||
if !isHTML {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
n := res.Status
|
||||
l := log.Info()
|
||||
msg := "Success"
|
||||
switch {
|
||||
case n >= 500:
|
||||
l = log.Error().Err(chainErr)
|
||||
// logger.With(zap.Error(chainErr)).Error("Server error", fields...)
|
||||
msg = "Server error"
|
||||
case n >= 400:
|
||||
msg = "Client error"
|
||||
case n >= 300:
|
||||
msg = "Redirect"
|
||||
}
|
||||
|
||||
if debug {
|
||||
l.Str("request", fmt.Sprintf("%s %s", req.Method, req.RequestURI))
|
||||
l.Int("status", res.Status)
|
||||
} else {
|
||||
l.Str("remote_ip", c.RealIP())
|
||||
l.Str("latency", time.Since(start).String())
|
||||
l.Str("host", req.Host)
|
||||
l.Str("request", fmt.Sprintf("%s %s", req.Method, req.RequestURI))
|
||||
l.Int("status", res.Status)
|
||||
l.Int64("size", res.Size)
|
||||
}
|
||||
|
||||
id := req.Header.Get(echo.HeaderXRequestID)
|
||||
if id != "" {
|
||||
l.Str("request_id", id)
|
||||
}
|
||||
|
||||
l.Msg(msg)
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
30
internal/server/middleware/security.go
Normal file
30
internal/server/middleware/security.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
echoMiddleware "github.com/labstack/echo/v4/middleware"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func Secure() echo.MiddlewareFunc {
|
||||
// logger := e.Logger.Desugar()
|
||||
// debug := e.DebugMode
|
||||
|
||||
// cfg := echomiddleware.SecureConfig{}
|
||||
cfg := echoMiddleware.DefaultSecureConfig
|
||||
return echoMiddleware.SecureWithConfig(cfg)
|
||||
}
|
||||
|
||||
func CSRF() echo.MiddlewareFunc {
|
||||
// we skip requests where cookie authentication was not used,
|
||||
// as these are api requests - not from the browser
|
||||
cfg := echoMiddleware.CSRFConfig{
|
||||
CookieName: "_csrf",
|
||||
CookiePath: "/",
|
||||
CookieSameSite: http.SameSiteStrictMode,
|
||||
Skipper: func(c echo.Context) bool {
|
||||
return !CheckCookieAuthUsed(c)
|
||||
},
|
||||
}
|
||||
return echoMiddleware.CSRFWithConfig(cfg)
|
||||
}
|
||||
59
internal/server/middleware/setup.go
Normal file
59
internal/server/middleware/setup.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
notSetupErr = "server not setup"
|
||||
invalidKeyErr = "setup key invalid"
|
||||
setupKeyHeader = "X-Setup-Key"
|
||||
)
|
||||
|
||||
func ServerSetupBlocker(e *env.Env, setupKey string) echo.MiddlewareFunc {
|
||||
logger := middlewareLogger("setup")
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
reqPath := c.Request().URL.Path
|
||||
|
||||
if !strings.HasPrefix(reqPath, "/api") {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
if reqPath == "/api/github/events" {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
if reqPath == "/api/setup/status" {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
isSetupRoute := strings.HasPrefix(reqPath, "/api/setup")
|
||||
if e.SetupCompleted {
|
||||
if isSetupRoute {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "already set up")
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
|
||||
providedKey := c.Request().Header.Get(setupKeyHeader)
|
||||
if providedKey != setupKey {
|
||||
logger.Debug().
|
||||
Str("path", reqPath).
|
||||
Str("provided", providedKey).
|
||||
Msg("invalid setup key")
|
||||
return echo.NewHTTPError(http.StatusForbidden, invalidKeyErr)
|
||||
}
|
||||
|
||||
if isSetupRoute {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
logger.Debug().Str("path", reqPath).Msg("not setup path")
|
||||
return echo.NewHTTPError(http.StatusForbidden, notSetupErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
29
internal/server/middleware/static.go
Normal file
29
internal/server/middleware/static.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
echomiddleware "github.com/labstack/echo/v4/middleware"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func staticFileSkipperFunc(c echo.Context) bool {
|
||||
if strings.HasPrefix(c.Request().URL.Path, "/api") {
|
||||
c.Set("static", false)
|
||||
return true
|
||||
}
|
||||
c.Set("static", true)
|
||||
return false
|
||||
}
|
||||
|
||||
func StaticFiles(staticFS fs.FS) echo.MiddlewareFunc {
|
||||
cfg := echomiddleware.StaticConfig{
|
||||
Root: "dist",
|
||||
Index: "app.html",
|
||||
HTML5: true,
|
||||
Filesystem: http.FS(staticFS),
|
||||
Skipper: staticFileSkipperFunc,
|
||||
}
|
||||
return echomiddleware.StaticWithConfig(cfg)
|
||||
}
|
||||
13
internal/server/rand.go
Normal file
13
internal/server/rand.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package server
|
||||
|
||||
import "math/rand"
|
||||
|
||||
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
|
||||
func generateRandomString(n int) string {
|
||||
b := make([]rune, n)
|
||||
for i := range b {
|
||||
b[i] = letterRunes[rand.Intn(len(letterRunes))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
67
internal/server/router.go
Normal file
67
internal/server/router.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gitlab.com/texm/shokku/internal/server/dto"
|
||||
"gitlab.com/texm/shokku/internal/server/middleware"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func initRouter(debugMode bool, appFS fs.FS) *echo.Echo {
|
||||
r := echo.New()
|
||||
r.Debug = debugMode
|
||||
r.HideBanner = true
|
||||
r.Validator = dto.NewRequestValidator()
|
||||
r.HTTPErrorHandler = errorHandler(debugMode)
|
||||
|
||||
r.Use(middleware.Recover(debugMode))
|
||||
r.Use(middleware.Secure())
|
||||
r.Use(middleware.RequestLogger(debugMode))
|
||||
r.Use(middleware.StaticFiles(appFS))
|
||||
r.Use(middleware.CSRF())
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func errorHandler(debug bool) func(error, echo.Context) {
|
||||
return func(err error, c echo.Context) {
|
||||
if c.Response().Committed {
|
||||
return
|
||||
}
|
||||
|
||||
httpErr, ok := err.(*echo.HTTPError)
|
||||
if ok && httpErr.Internal != nil {
|
||||
if herr, ok := httpErr.Internal.(*echo.HTTPError); ok {
|
||||
httpErr = herr
|
||||
}
|
||||
} else if !ok {
|
||||
httpErr = &echo.HTTPError{
|
||||
Code: http.StatusInternalServerError,
|
||||
Message: http.StatusText(http.StatusInternalServerError),
|
||||
}
|
||||
}
|
||||
|
||||
// Issue #1426
|
||||
code := httpErr.Code
|
||||
message := httpErr.Message
|
||||
if m, strOk := httpErr.Message.(string); strOk {
|
||||
if debug {
|
||||
message = echo.Map{"message": m, "error": httpErr.Error()}
|
||||
} else {
|
||||
message = echo.Map{"message": m}
|
||||
}
|
||||
}
|
||||
|
||||
// Send response
|
||||
if c.Request().Method == http.MethodHead { // Issue #608
|
||||
err = c.NoContent(httpErr.Code)
|
||||
} else {
|
||||
err = c.JSON(code, message)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("error handler response")
|
||||
}
|
||||
}
|
||||
}
|
||||
47
internal/server/secrets.go
Normal file
47
internal/server/secrets.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rsa"
|
||||
"database/sql"
|
||||
"encoding/gob"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gitlab.com/texm/shokku/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type secrets struct {
|
||||
signingKey []byte
|
||||
privKey *rsa.PrivateKey
|
||||
}
|
||||
|
||||
func getServerSecrets(db *gorm.DB) (*secrets, error) {
|
||||
var s models.ServerSecrets
|
||||
if err := db.Find(&s).Error; err != nil && err != sql.ErrNoRows {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Msg("failed to get server secrets")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var key *rsa.PrivateKey
|
||||
if len(s.DokkuSSHKeyGob) == 0 {
|
||||
return nil, errors.New("no ssh key stored")
|
||||
}
|
||||
|
||||
r := bytes.NewReader(s.DokkuSSHKeyGob)
|
||||
if decodeErr := gob.NewDecoder(r).Decode(&key); decodeErr != nil {
|
||||
return nil, fmt.Errorf("failed to decode priv key: %w", decodeErr)
|
||||
}
|
||||
|
||||
if validErr := key.Validate(); validErr != nil {
|
||||
return nil, fmt.Errorf("private key validation failed: %w", validErr)
|
||||
}
|
||||
|
||||
return &secrets{
|
||||
privKey: key,
|
||||
signingKey: s.SigningKey,
|
||||
}, nil
|
||||
}
|
||||
75
internal/server/server.go
Normal file
75
internal/server/server.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/server/api"
|
||||
"gitlab.com/texm/shokku/internal/server/commands"
|
||||
"gitlab.com/texm/shokku/internal/server/db"
|
||||
"gitlab.com/texm/shokku/internal/server/dokku"
|
||||
"gitlab.com/texm/shokku/internal/server/middleware"
|
||||
"io/fs"
|
||||
"time"
|
||||
)
|
||||
|
||||
func New(cfg Config, appFS fs.FS) (*env.Env, error) {
|
||||
e := env.New(cfg.DebugMode)
|
||||
e.Router = initRouter(cfg.DebugMode, appFS)
|
||||
|
||||
dbSess, dbErr := db.Init(cfg.DBPath)
|
||||
if dbErr != nil {
|
||||
return nil, fmt.Errorf("failed to init db: %w", dbErr)
|
||||
}
|
||||
e.DB = dbSess
|
||||
|
||||
serverSecrets, secretsErr := getServerSecrets(e.DB)
|
||||
if secretsErr != nil {
|
||||
return nil, fmt.Errorf("failed to get server secrets: %w", secretsErr)
|
||||
}
|
||||
|
||||
dokkuCfg := dokku.Config{
|
||||
DebugMode: cfg.DebugMode,
|
||||
PrivateKey: serverSecrets.privKey,
|
||||
Host: cfg.DokkuSSHHost,
|
||||
Port: cfg.DokkuSSHPort,
|
||||
}
|
||||
dokkuClient, dokkuErr := dokku.Init(dokkuCfg)
|
||||
if dokkuErr != nil {
|
||||
return nil, fmt.Errorf("failed to init dokku client: %w", dokkuErr)
|
||||
}
|
||||
e.Dokku = dokkuClient
|
||||
|
||||
serverState, stateErr := initServerState(e)
|
||||
if stateErr != nil {
|
||||
return nil, fmt.Errorf("failed to get setup state: %w", stateErr)
|
||||
}
|
||||
|
||||
authCfg := initAuthConfig{
|
||||
SigningKey: serverSecrets.signingKey,
|
||||
TokenLifetime: time.Minute * time.Duration(cfg.AuthTokenLifetimeMinutes),
|
||||
Method: serverState.AuthMethod,
|
||||
DebugMode: cfg.DebugMode,
|
||||
IsSetup: serverState.IsSetup,
|
||||
}
|
||||
authn, authErr := initAuthenticator(authCfg)
|
||||
if authErr != nil {
|
||||
return nil, fmt.Errorf("failed to init authenticator: %w", authErr)
|
||||
}
|
||||
e.Auth = authn
|
||||
|
||||
if !e.SetupCompleted {
|
||||
e.Router.Use(middleware.ServerSetupBlocker(e, serverState.SetupKey))
|
||||
}
|
||||
|
||||
api.RegisterRoutes(e, []echo.MiddlewareFunc{
|
||||
middleware.TokenAuth(e),
|
||||
middleware.ProvideUserContext(e),
|
||||
})
|
||||
|
||||
dokku.SyncState(e)
|
||||
|
||||
go commands.PollStatuses()
|
||||
|
||||
return e, nil
|
||||
}
|
||||
72
internal/server/setup.go
Normal file
72
internal/server/setup.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gitlab.com/texm/shokku/internal/env"
|
||||
"gitlab.com/texm/shokku/internal/models"
|
||||
"gitlab.com/texm/shokku/internal/server/auth"
|
||||
"gitlab.com/texm/shokku/internal/server/github"
|
||||
"time"
|
||||
)
|
||||
|
||||
func maybeSyncState(e *env.Env, state *models.Server) error {
|
||||
if state.AuthMethod == auth.MethodGithub {
|
||||
timeSinceLast := time.Since(state.LastSync)
|
||||
needsSync := timeSinceLast > 6*time.Hour
|
||||
if !needsSync {
|
||||
return nil
|
||||
}
|
||||
if err := github.SyncUsersToDB(e); err != nil {
|
||||
return fmt.Errorf("failed to sync github state: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
state.LastSync = time.Now()
|
||||
if err := e.DB.Save(state).Error; err != nil {
|
||||
return fmt.Errorf("failed to update last sync time")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func initServerState(e *env.Env) (*models.Server, error) {
|
||||
var state models.Server
|
||||
if err := e.DB.FirstOrCreate(&state).Error; err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Msg("failed to get state")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Bool("is_setup", state.IsSetup).
|
||||
Str("auth_method", string(state.AuthMethod)).
|
||||
Time("last_sync", state.LastSync).
|
||||
Msg("server setup state")
|
||||
|
||||
e.SetupCompleted = state.IsSetup
|
||||
|
||||
if state.IsSetup {
|
||||
if syncErr := maybeSyncState(e, &state); syncErr != nil {
|
||||
return nil, syncErr
|
||||
}
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
if state.SetupKey == "" {
|
||||
state.SetupKey = generateRandomString(16)
|
||||
if err := e.DB.Save(&state).Error; err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Msg("failed to update setup key")
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("setup_key", state.SetupKey).
|
||||
Msg("running in setup mode")
|
||||
|
||||
return &state, nil
|
||||
}
|
||||
35
internal/server/websocket.go
Normal file
35
internal/server/websocket.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type wsErrorFunc func(w http.ResponseWriter, r *http.Request, status int, reason error)
|
||||
|
||||
func createWSCheckOriginFunc(origin string) func(r *http.Request) bool {
|
||||
return func(req *http.Request) bool {
|
||||
log.Debug().
|
||||
Str("origin", req.Header.Get("Origin")).
|
||||
Str("host", origin).
|
||||
Msg("ws check origin")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func createWSErrorFunc(e *echo.Echo) wsErrorFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, status int, reason error) {
|
||||
err := echo.NewHTTPError(status, reason)
|
||||
c := e.NewContext(r, w)
|
||||
e.HTTPErrorHandler(err, c)
|
||||
}
|
||||
}
|
||||
|
||||
func initWebsocketUpgrader(cfg Config, router *echo.Echo) websocket.Upgrader {
|
||||
return websocket.Upgrader{
|
||||
CheckOrigin: createWSCheckOriginFunc(cfg.Host),
|
||||
Error: createWSErrorFunc(router),
|
||||
}
|
||||
}
|
||||
2626
ui/package-lock.json
generated
Normal file
2626
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
ui/package.json
Normal file
32
ui/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "shokku-ui",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prettier": "prettier --write --plugin-search-dir=. ./**/*.svelte"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "next",
|
||||
"@sveltejs/adapter-static": "^1.0.0-next.29",
|
||||
"@sveltejs/kit": "next",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"cssnano": "^5.1.8",
|
||||
"postcss": "^8.4.21",
|
||||
"prettier": "^2.8.1",
|
||||
"prettier-plugin-svelte": "^2.9.0",
|
||||
"svelte": "^3.44.0",
|
||||
"tabler-icons-svelte": "^1.8.0",
|
||||
"tailwindcss": "^3.2.4"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@sveltestack/svelte-query": "^1.6.0",
|
||||
"daisyui": "^2.14.3",
|
||||
"js-cookie": "^3.0.1",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"svelte-file-dropzone": "^1.0.0",
|
||||
"wretch": "^1.7.9"
|
||||
}
|
||||
}
|
||||
6
ui/postcss.config.cjs
Normal file
6
ui/postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
7
ui/src/app.css
Normal file
7
ui/src/app.css
Normal file
@@ -0,0 +1,7 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
/*height: 100vh;*/
|
||||
}
|
||||
13
ui/src/app.html
Normal file
13
ui/src/app.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<!--html lang="en" data-theme="light"-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body>
|
||||
<div>%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
49
ui/src/components/Header.svelte
Normal file
49
ui/src/components/Header.svelte
Normal file
@@ -0,0 +1,49 @@
|
||||
<script>
|
||||
import Icon from "$common/Icon.svelte";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
export let isAuthenticated = false;
|
||||
export let showExecutionButton = false;
|
||||
export let executionWindowVisible = false;
|
||||
|
||||
const authenticatedLinks = [
|
||||
{ href: "/settings", text: "Settings" },
|
||||
{ href: "/logout", text: "Logout" },
|
||||
];
|
||||
const linkStyle = "px-3 py-2 btn btn-ghost";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const executionButtonClicked = () => dispatch("executionButtonClicked");
|
||||
</script>
|
||||
|
||||
<nav
|
||||
class="bg-neutral text-neutral-content flex flex-col md:flex-row items-center w-full justify-between p-4 mx-auto"
|
||||
>
|
||||
<a
|
||||
class="hover:font-bold inline-flex items-center justify-center btn btn-ghost normal-case text-xl"
|
||||
href="/">shokku</a
|
||||
>
|
||||
|
||||
{#if showExecutionButton}
|
||||
<button
|
||||
class="btn gap-2 hover:btn-secondary"
|
||||
class:btn-active={executionWindowVisible}
|
||||
on:click={executionButtonClicked}
|
||||
>
|
||||
<Icon type="file-text" />
|
||||
command output
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col">
|
||||
<ul class="flex flex-col md:flex-row items-center space-x-2 text-sm font-medium">
|
||||
{#if isAuthenticated}
|
||||
{#each authenticatedLinks as link}
|
||||
<li>
|
||||
<a class={linkStyle} href={link["href"]}>{link["text"]}</a>
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
47
ui/src/components/commands/CommandExecution.svelte
Normal file
47
ui/src/components/commands/CommandExecution.svelte
Normal file
@@ -0,0 +1,47 @@
|
||||
<script>
|
||||
import Loader from "$common/Loader.svelte";
|
||||
|
||||
export let status = {};
|
||||
|
||||
let msgContainer;
|
||||
let finished = false;
|
||||
let output = [];
|
||||
let bg = "bg-base-200";
|
||||
$: if (status) {
|
||||
let outputPrev = output.length;
|
||||
output = status["output"] || [];
|
||||
finished = status["finished"];
|
||||
if (status["finished"] && !status["success"]) {
|
||||
bg = "bg-error";
|
||||
}
|
||||
|
||||
if (msgContainer) {
|
||||
msgContainer.scroll({
|
||||
top: msgContainer.scrollHeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-80 max-h-96 overflow-x-scroll" bind:this={msgContainer}>
|
||||
{#each output as line, i}
|
||||
<pre data-prefix={i} class:text-warning={line["type"] === "stderr"}><code
|
||||
>{line["message"]}</code
|
||||
></pre>
|
||||
{/each}
|
||||
|
||||
{#if !finished}
|
||||
<div class="m-4">
|
||||
<Loader />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
pre[data-prefix]:before {
|
||||
content: attr(data-prefix);
|
||||
width: 2rem;
|
||||
margin-right: 2ch;
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
88
ui/src/components/commands/CommandExecutionWindow.svelte
Normal file
88
ui/src/components/commands/CommandExecutionWindow.svelte
Normal file
@@ -0,0 +1,88 @@
|
||||
<script>
|
||||
import {
|
||||
commandExecutionIds,
|
||||
commandExecutions,
|
||||
executionIdDescriptions,
|
||||
} from "$lib/stores";
|
||||
import Icon from "$common/Icon.svelte";
|
||||
import CommandExecution from "./CommandExecution.svelte";
|
||||
|
||||
export let watchingCompleted = false;
|
||||
|
||||
let selectedId;
|
||||
let executions = {};
|
||||
let ids = [];
|
||||
$: if ($commandExecutionIds !== null) {
|
||||
let allIds = new Set();
|
||||
for (let id in executions) allIds.add(id);
|
||||
for (let i in $commandExecutionIds) allIds.add($commandExecutionIds[i]);
|
||||
ids = [...allIds];
|
||||
|
||||
let newId;
|
||||
for (let i in ids) {
|
||||
const id = ids[i];
|
||||
if (!executions[id]) newId = id;
|
||||
executions[id] = $commandExecutions[id] || [];
|
||||
}
|
||||
|
||||
if (newId) selectedId = newId;
|
||||
|
||||
if ($commandExecutionIds.length > 0 && !selectedId) {
|
||||
selectedId = $commandExecutionIds[$commandExecutionIds.length - 1];
|
||||
}
|
||||
|
||||
watchingCompleted = Object.keys(executions).length > 0;
|
||||
}
|
||||
|
||||
const removeSelectedId = () => {
|
||||
commandExecutionIds.remove(selectedId);
|
||||
let newExec = {};
|
||||
let nextId;
|
||||
for (const id in executions) {
|
||||
if (id !== selectedId) {
|
||||
newExec[id] = executions[id];
|
||||
nextId = id;
|
||||
}
|
||||
}
|
||||
executions = newExec;
|
||||
selectedId = nextId;
|
||||
|
||||
watchingCompleted = Object.keys(executions).length > 0;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bg-neutral rounded-lg shadow-lg border-info border-2 text-neutral-content w-full h-fit min-h-16 p-2"
|
||||
>
|
||||
<div class="flex flex-row items-center">
|
||||
<ul class="menu menu-compact menu-horizontal items-center rounded-box p-1">
|
||||
{#each Object.keys(executions) as id}
|
||||
<li>
|
||||
<a
|
||||
class:active={id === selectedId}
|
||||
on:click={() => (selectedId = id)}
|
||||
>
|
||||
{$executionIdDescriptions[id]}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
{#if selectedId && executions[selectedId]}
|
||||
<div class="items-center">
|
||||
<button
|
||||
class="btn btn-sm btn-square hover:btn-error"
|
||||
on:click={removeSelectedId}
|
||||
>
|
||||
<Icon type="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selectedId}
|
||||
<CommandExecution status={executions[selectedId]} />
|
||||
{/if}
|
||||
</div>
|
||||
94
ui/src/components/commands/Terminal.svelte
Normal file
94
ui/src/components/commands/Terminal.svelte
Normal file
@@ -0,0 +1,94 @@
|
||||
<script>
|
||||
import { afterUpdate, beforeUpdate } from "svelte";
|
||||
|
||||
export let onInput;
|
||||
export let output = [];
|
||||
|
||||
let bg = "bg-base-200";
|
||||
let input = "";
|
||||
|
||||
const defaultAppend = (lines) => {
|
||||
output = [...output, ...lines];
|
||||
};
|
||||
|
||||
const echoInput = (s) => {
|
||||
defaultAppend({ output: s });
|
||||
};
|
||||
|
||||
$: if (!onInput) onInput = echoInput;
|
||||
|
||||
const KEY_ENTER = 13;
|
||||
const inputKeyPress = (e) => {
|
||||
if (e.charCode !== KEY_ENTER) return;
|
||||
|
||||
onInput(input);
|
||||
input = "";
|
||||
};
|
||||
|
||||
let autoscroll;
|
||||
let scrollDiv;
|
||||
beforeUpdate(() => {
|
||||
autoscroll =
|
||||
scrollDiv &&
|
||||
scrollDiv.offsetHeight + scrollDiv.scrollTop >
|
||||
scrollDiv.scrollHeight - 20;
|
||||
});
|
||||
|
||||
afterUpdate(() => {
|
||||
if (autoscroll) scrollDiv.scrollTo(0, scrollDiv.scrollHeight);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="flex flex-shrink items-center px-2 w-full h-8 bg-neutral-focus text-neutral-content"
|
||||
>
|
||||
<slot name="titlebar" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-grow max-w-80 h-80 overflow-x-scroll bg-base-content p-2"
|
||||
bind:this={scrollDiv}
|
||||
>
|
||||
<div class="whitespace-pre-wrap flex flex-col">
|
||||
{#each output as line, i}
|
||||
{#if line.input}
|
||||
<span class="text-base-100/70 mt-2" data-prefix=">"
|
||||
><code class="text-inherit">{line.input}</code></span
|
||||
>
|
||||
{/if}
|
||||
{#if line.output}
|
||||
<span class="text-neutral-content"
|
||||
><code class="text-inherit">{line.output}</code></span
|
||||
>
|
||||
{/if}
|
||||
{#if line.error}
|
||||
<span class="text-warning"
|
||||
><code class="text-inherit">{line.error}</code></span
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-shrink px-2 flex flex-row gap-2 min-h-12 w-full h-12 bg-neutral-focus text-neutral-content"
|
||||
>
|
||||
<span class="text-md self-center">></span>
|
||||
<input
|
||||
type="text"
|
||||
on:keypress={inputKeyPress}
|
||||
bind:value={input}
|
||||
class="input px-0 bg-neutral-focus rounded-t-none py-1 input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
span[data-prefix]:before {
|
||||
content: attr(data-prefix);
|
||||
width: 2rem;
|
||||
margin-right: 1ch;
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
27
ui/src/components/common/Alert.svelte
Normal file
27
ui/src/components/common/Alert.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let message;
|
||||
export let type = "error";
|
||||
export let canDismiss = false;
|
||||
|
||||
const clicked = () => {
|
||||
if (canDismiss) message = "";
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="mt-2" class:hidden={!message || message === ""}>
|
||||
<div
|
||||
class="alert alert-error shadow-lg"
|
||||
class:cursor-pointer={canDismiss}
|
||||
on:click={clicked}
|
||||
>
|
||||
<div>
|
||||
<Icon {type} />
|
||||
<span class="text-error-content">{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
14
ui/src/components/common/Card.svelte
Normal file
14
ui/src/components/common/Card.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script>
|
||||
export let title = "";
|
||||
export let actionsRight = false;
|
||||
</script>
|
||||
|
||||
<div class="card shadow-lg bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="card-title">{title}</h2>
|
||||
<slot />
|
||||
<div class="card-actions" class:justify-end={actionsRight}>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
3
ui/src/components/common/Cards.svelte
Normal file
3
ui/src/components/common/Cards.svelte
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="flex flex-col gap-5">
|
||||
<slot />
|
||||
</div>
|
||||
7
ui/src/components/common/CenterCard.svelte
Normal file
7
ui/src/components/common/CenterCard.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<div class="flex flex-row max-h-full justify-center">
|
||||
<div class="card flex-shrink-0 w-full max-w-xl max-h-full">
|
||||
<div class="card-body overflow-scroll">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
10
ui/src/components/common/Code.svelte
Normal file
10
ui/src/components/common/Code.svelte
Normal file
@@ -0,0 +1,10 @@
|
||||
<script>
|
||||
export let lines = [];
|
||||
export let prefix = "";
|
||||
</script>
|
||||
|
||||
<div class="mockup-code text-left w-fit">
|
||||
{#each lines as line}
|
||||
<pre data-prefix={prefix}><code>{line}</code></pre>
|
||||
{/each}
|
||||
</div>
|
||||
53
ui/src/components/common/ConfirmationModal.svelte
Normal file
53
ui/src/components/common/ConfirmationModal.svelte
Normal file
@@ -0,0 +1,53 @@
|
||||
<script>
|
||||
import Modal from "./Modal.svelte";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
export let name;
|
||||
export let title = "Confirm";
|
||||
export let action = "foo the bar";
|
||||
export let open = false;
|
||||
export let doingAction = false;
|
||||
export let extraOption = "";
|
||||
|
||||
let extraOptionChecked = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const close = () => (open = false);
|
||||
const accept = () => {
|
||||
dispatch("accepted", {
|
||||
extraOptionChecked,
|
||||
});
|
||||
close();
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal {name} {title} bind:open preventClose={doingAction}>
|
||||
<div class="mb-4">
|
||||
<span class="text-md">Are you sure you want to {action}?</span>
|
||||
|
||||
{#if extraOption}
|
||||
<div
|
||||
class="form-control w-52 border-2 border-base-200 rounded-lg mt-2 p-2"
|
||||
>
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text">{extraOption}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={extraOptionChecked}
|
||||
class="checkbox"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" class:loading={doingAction} on:click={accept}
|
||||
>Yes</button
|
||||
>
|
||||
<button
|
||||
class="btn btn-secondary btn-ghost"
|
||||
class:loading={doingAction}
|
||||
on:click={close}>No</button
|
||||
>
|
||||
</Modal>
|
||||
27
ui/src/components/common/ContentPage.svelte
Normal file
27
ui/src/components/common/ContentPage.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<div class="card card-body py-3 px-3 h-full max-h-full text-base-content">
|
||||
<div class="hidden sm:flex rounded-lg flex-row w-full max-h-full">
|
||||
<slot name="sidebar" />
|
||||
|
||||
<div class="pl-3 h-full w-full flex flex-col overflow-x-scroll">
|
||||
<div class="w-full">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
|
||||
<div class="overflow-scroll w-full h-full rounded-lg">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- mobile nav -->
|
||||
<div class="inline-block sm:hidden">
|
||||
<slot name="header" />
|
||||
<slot name="sidebar" />
|
||||
|
||||
<div class="overflow-scroll h-full">
|
||||
<div class="rounded-lg w-full h-full overflow-scroll pb-6">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
37
ui/src/components/common/Error.svelte
Normal file
37
ui/src/components/common/Error.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script>
|
||||
import Icon from "$components/common/Icon.svelte";
|
||||
|
||||
export let action;
|
||||
export let error;
|
||||
export let errorMessage = null;
|
||||
|
||||
let displayMessage = errorMessage;
|
||||
$: if (error && !errorMessage) {
|
||||
try {
|
||||
let parsed = JSON.parse(error.message);
|
||||
if (parsed.message) {
|
||||
let code = parsed.error.split(",")[0].substring(5, 8);
|
||||
displayMessage = `HTTP ${code}: ${parsed.message}`;
|
||||
} else {
|
||||
displayMessage = `${parsed.type} error`;
|
||||
}
|
||||
} catch {
|
||||
displayMessage = error;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card bg-error shadow-xl my-2">
|
||||
<div class="card-body text-error-content">
|
||||
<div class="grid grid-cols-3 items-center mb-4">
|
||||
<div class="col-span-2 text-neutral-content">
|
||||
<span class="text-xl leading-8">Error {action}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Icon type="warning" />
|
||||
</div>
|
||||
</div>
|
||||
<p>{displayMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
114
ui/src/components/common/Icon.svelte
Normal file
114
ui/src/components/common/Icon.svelte
Normal file
@@ -0,0 +1,114 @@
|
||||
<script>
|
||||
import CirclePlus from "tabler-icons-svelte/icons/CirclePlus.svelte";
|
||||
import CircleMinus from "tabler-icons-svelte/icons/CircleMinus.svelte";
|
||||
import Copy from "tabler-icons-svelte/icons/Copy.svelte";
|
||||
import Trash from "tabler-icons-svelte/icons/Trash.svelte";
|
||||
import Cloud from "tabler-icons-svelte/icons/Cloud.svelte";
|
||||
import RotateClockwise2 from "tabler-icons-svelte/icons/RotateClockwise2.svelte";
|
||||
import Stack from "tabler-icons-svelte/icons/Stack.svelte";
|
||||
import Folder from "tabler-icons-svelte/icons/Folder.svelte";
|
||||
import Link from "tabler-icons-svelte/icons/Link.svelte";
|
||||
import Unlink from "tabler-icons-svelte/icons/Unlink.svelte";
|
||||
import DeviceFloppy from "tabler-icons-svelte/icons/DeviceFloppy.svelte";
|
||||
import Pencil from "tabler-icons-svelte/icons/Pencil.svelte";
|
||||
import Tool from "tabler-icons-svelte/icons/Tool.svelte";
|
||||
import Box from "tabler-icons-svelte/icons/Box.svelte";
|
||||
import ChartCircles from "tabler-icons-svelte/icons/ChartCircles.svelte";
|
||||
import LayersSubtract from "tabler-icons-svelte/icons/LayersSubtract.svelte";
|
||||
import InfoCircle from "tabler-icons-svelte/icons/InfoCircle.svelte";
|
||||
import FileText from "tabler-icons-svelte/icons/FileText.svelte";
|
||||
import AlertCircle from "tabler-icons-svelte/icons/AlertCircle.svelte";
|
||||
import BrandGit from "tabler-icons-svelte/icons/BrandGit.svelte";
|
||||
import Database from "tabler-icons-svelte/icons/Database.svelte";
|
||||
import Settings from "tabler-icons-svelte/icons/Settings.svelte";
|
||||
import PlayerPlay from "tabler-icons-svelte/icons/PlayerPlay.svelte";
|
||||
import PlayerStop from "tabler-icons-svelte/icons/PlayerStop.svelte";
|
||||
import Upload from "tabler-icons-svelte/icons/Upload.svelte";
|
||||
import Terminal2 from "tabler-icons-svelte/icons/Terminal2.svelte";
|
||||
import ArrowLeft from "tabler-icons-svelte/icons/ArrowLeft.svelte";
|
||||
import ArrowRight from "tabler-icons-svelte/icons/ArrowRight.svelte";
|
||||
import Key from "tabler-icons-svelte/icons/Key.svelte";
|
||||
import ExternalLink from "tabler-icons-svelte/icons/ExternalLink.svelte";
|
||||
|
||||
// using https://devicon.dev
|
||||
import Postgres from "./devicons/Postgres.svelte";
|
||||
import Redis from "./devicons/Redis.svelte";
|
||||
import SQLite from "./devicons/SQLite.svelte";
|
||||
import MySQL from "./devicons/MySQL.svelte";
|
||||
import MongoDB from "./devicons/MongoDB.svelte";
|
||||
import Github from "./devicons/Github.svelte";
|
||||
import Docker from "./devicons/Docker.svelte";
|
||||
import Go from "./devicons/Go.svelte";
|
||||
import Javascript from "./devicons/Javascript.svelte";
|
||||
import Python from "./devicons/Python.svelte";
|
||||
import Ruby from "./devicons/Ruby.svelte";
|
||||
|
||||
export let type;
|
||||
export let size = "md";
|
||||
|
||||
const icons = {
|
||||
add: CirclePlus,
|
||||
remove: CircleMinus,
|
||||
delete: Trash,
|
||||
"external-link": ExternalLink,
|
||||
docker: Docker,
|
||||
go: Go,
|
||||
javascript: Javascript,
|
||||
python: Python,
|
||||
ruby: Ruby,
|
||||
cloud: Cloud,
|
||||
copy: Copy,
|
||||
restart: RotateClockwise2,
|
||||
build: Stack,
|
||||
rebuild: Stack,
|
||||
folder: Folder,
|
||||
link: Link,
|
||||
unlink: Unlink,
|
||||
save: DeviceFloppy,
|
||||
plus: CirclePlus,
|
||||
edit: Pencil,
|
||||
cube: Box,
|
||||
left: ArrowLeft,
|
||||
right: ArrowRight,
|
||||
key: Key,
|
||||
circles: ChartCircles,
|
||||
layers: LayersSubtract,
|
||||
info: InfoCircle,
|
||||
"file-text": FileText,
|
||||
warning: AlertCircle,
|
||||
error: AlertCircle,
|
||||
git: BrandGit,
|
||||
terminal: Terminal2,
|
||||
database: Database,
|
||||
spanner: Tool,
|
||||
settings: Settings,
|
||||
start: PlayerPlay,
|
||||
stop: PlayerStop,
|
||||
upload: Upload,
|
||||
postgres: Postgres,
|
||||
redis: Redis,
|
||||
sqlite: SQLite,
|
||||
mysql: MySQL,
|
||||
mongodb: MongoDB,
|
||||
mongo: MongoDB,
|
||||
github: Github,
|
||||
};
|
||||
const sizeClasses = {
|
||||
xs: "w-3 h-3",
|
||||
sm: "w-5 h-5",
|
||||
md: "w-6 h-6",
|
||||
lg: "w-8 h-8",
|
||||
xl: "w-10 h-10",
|
||||
};
|
||||
const sizes = {
|
||||
xs: "14",
|
||||
sm: "20",
|
||||
md: "24",
|
||||
lg: "32",
|
||||
xl: "38",
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class={sizeClasses[size]}>
|
||||
<svelte:component this={icons[type]} size={sizes[size]} />
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user