├── README.md ├── couchdb-setup ├── Dockerfile ├── README.md └── couchdb-setup.sh ├── couchdb-worker ├── Dockerfile ├── README.md ├── couchdb-worker.sh ├── global-changes-feed.sh └── workers │ ├── provision-repos.sh │ └── provision-users.sh ├── docker-compose.yml ├── git-server ├── 000-default.conf ├── Dockerfile ├── README.md ├── couchdb-auth │ ├── README.md │ └── couchdb-auth.sh └── couchdb-git-hook │ ├── README.md │ └── couchdb-git-hook.sh └── webapp ├── Dockerfile ├── README.md ├── app.js ├── couch.js ├── index.html └── style.css /README.md: -------------------------------------------------------------------------------- 1 | # Git Relax 2 | For all the gitlovers 'round here, you offline sync evangelists, text enthusiasts. This is for you. 3 | 4 | CouchDB is the tool of choice for synchronizing structured data. But when it comes to plaintext change management, nothing can beat Git. Lets have them both. And a changes feed for Git. 5 | 6 | What we will get with Git Relax is: 7 | 8 | * CouchDB & Git Hosting 9 | * User Management done by CouchDB 10 | * Per user CouchDB databases 11 | * Multiple per user Git repositories 12 | * Create Git repositories via HTTP PUT (create CouchDB document) 13 | * CouchDB Changes feed for Git pushes 14 | * CORS enabled, web ready 15 | 16 | This project describes a minimal example setup, a proof of concept, based on standard components. For scripting, we use Bash. 17 | 18 | 19 | ## Components 20 | Git Relax is built on top of Apache CouchDB 3.0 and Git, served via Apache2. On top of that we implement a **custom authenticator** for Apache to authenticate against CouchDB, a **Git hook** to publish changes to CouchDB user databases, **a worker** which manages user databases and repositories and a small webapp, which provides an exemplary user interface. 21 | 22 | 23 | ### CouchDB 24 | We gonna use latest CouchDB 3.0. 25 | 26 | Before we can start we´ll configure the cluster, global changes feed, public signup, enable CORS and more. 27 | 28 | See [couchdb-setup](couchdb-setup) for more information and the complete script. 29 | 30 | 31 | ### CouchDB Auth 32 | User management is done by Couch and we want the Apache Webserver serving our Git repositories to authenticate against it. 33 | 34 | The [couchdb-auth](git-server/couchdb-auth) script receives credentials from Apache and checks them via a query to CouchDB `/_session`. 35 | 36 | 37 | ### CouchDB Git Hook 38 | When we push to a Git repository we create a CouchDB document in the users database. This is done by installing a Git `post-receive` hook in the users repositories. 39 | 40 | Read [couchdb-git-hook](git-server/couchdb-git-hook) for more information. 41 | 42 | 43 | ### CouchDB Worker 44 | This is where we connect all the things. The worker listens to CouchDB changes feeds and reacts on specific changes. 45 | 46 | 1. We listen to a global CouchDB changes feed 47 | 1. For user signups, create a user database 48 | 1. For repo documents, initialize a repository 49 | 50 | See [couchdb-worker](couchdb-worker) for more information. 51 | 52 | 53 | ### Git Server 54 | We serve our Git repositories via HTTP using Apache2. Authentication is done against CouchDB, see above. 55 | 56 | Please have a look at [git-server](git-server) which contains the complete configuration. 57 | 58 | 59 | ### Webapp 60 | A small [webapp](webapp) provides basic user interface to Git Relax. 61 | 62 | 63 | 64 | ## API 65 | Above we looked at the components. Here is what we get: Git Relax exposes two APIs, the Git Smart HTTP and a user and repository management HTTP API via CouchDB. 66 | 67 | ### User Management 68 | Create users (signup), update password, login, logout and delete users. 69 | 70 | #### Signup 71 | a `PUT http://localhost:5984/_users/org.apache.user:eva` with the following data 72 | ```json 73 | { 74 | "_id": "org.couchdb.user:eva", 75 | "name": "eva", 76 | "roles": [], 77 | "type": "user", 78 | "password": "eva" 79 | } 80 | ``` 81 | 82 | will signup the user `eva`. Once the worker has finished, it will insert a `provisionedAt` stamp, to indicate the user database has been completely provisioned: 83 | ```json 84 | { 85 | "_id": "org.couchdb.user:eva", 86 | "_rev": "2-89695ec69bf034daca2fd2bed4a71ce8", 87 | "name": "eva", 88 | "roles": [], 89 | "type": "user", 90 | "password_scheme": "pbkdf2", 91 | "iterations": 10, 92 | "derived_key": "f04aaa47b1533a720003b8ae0e50d0afbb8ae004", 93 | "salt": "4b05ca0e8d1fcbc1ac14b74e389075b5", 94 | "provisionedAt": "2020-04-03T10:20:18+00:00" 95 | } 96 | ``` 97 | 98 | ### Login 99 | Now we can ask for a session cookie, via a `POST http://localhost:5984/_session`, supplying credentials: 100 | ```json 101 | { 102 | "name": "eva", 103 | "password": "eva" 104 | } 105 | ``` 106 | 107 | and we'l get back a cookie like this: 108 | 109 | ``` 110 | AuthSession=ZXZhOjVFODg0ODBEOvh6xalPAhvqCGWwRfvvWQfgOSif 111 | ``` 112 | 113 | Having such cookie we can get the session information with a `GET http://localhost:5984/_session`. 114 | 115 | 116 | ### Repository Management 117 | Repositories are stored inside the user database. The user demands a repo by pushing a repository request document to her database: `PUT http://localhost:5984/eva/repo:myrepo` with data 118 | ```json 119 | { 120 | "_id": "repo:myrepo", 121 | "requestedAt": "2020-04-01T17:40:24+02:00" 122 | } 123 | ``` 124 | 125 | After the worker has created the repo, the document will include a `provisionedAt` stamp: 126 | ```json 127 | { 128 | "_id": "repo:myrepo", 129 | "_rev": "2-ff197c792d754b7666529898cbcae13c", 130 | "requestedAt": "2020-04-01T17:40:24+02:00", 131 | "provisionedAt": "2020-04-01T17:40:55+02:00" 132 | } 133 | ``` 134 | 135 | ### Git Pushes 136 | Git pushes are synced to the user database. For every push there will be a document created with information about the repo, branch and revision. This, for example, is a push to `master` branch: 137 | ```json 138 | { 139 | "_id": "repo:myrepo:branch:master:ref:2fec5028e492bee6395d77107ae0debd3dd855f2", 140 | "_rev": "1-967a00dff5e02add41819138abb3284d", 141 | "receivedAt": "2020-04-01T19:12:23+02:00" 142 | } 143 | ``` 144 | 145 | 146 | ### Changes Feed 147 | The user can listen to a changes feed on their database using the `_changes` api: 148 | ``` 149 | GET http://localhost:5984/eva/_changes 150 | ``` 151 | 152 | This feed has various options for creating a continuous update stream. See [the `/db/_changes` feed documentation](https://docs.couchdb.org/en/stable/api/database/changes.html) for detailed information. 153 | 154 | 155 | ### Git Smart HTTP 156 | Git Repositories are available under `http://localhost:8080/eva/.git`. 157 | 158 | 159 | ## Docker 160 | All above has been implemented as a docker compose swarm: 161 | 162 | * _couchdb_: runs CouchDB 3.0 163 | * _couchdb-setup_: run setup script and exit afterwards 164 | * _couchdb-worker_: runs the worker 165 | * _git-server_: serves the Git repositories 166 | * _webapp_: serves the webapp 167 | 168 | Start it with 169 | 170 | ```sh 171 | docker-compose up --build 172 | ``` 173 | 174 | Now you'll get the three endpoints: 175 | 176 | * App: http://localhost:3000/ 177 | * CouchDB: http://localhost:5984/ 178 | * Git: http://localhost:8080/ 179 | 180 | 181 | Thats basically it so far. Look through the components in this repository, most of its directories contain READMEs with more detailed information. This is just the minimal setup to create a Git Relax development infrastructure and proof of concept to play with. From here on we can make up our minds about how to deploy and scale that thing, do benchmarks and optimize performance. The basics are clear and based on standards all over: Git, HTTP, UNIX Pipes, Apache2 etc. Both Git and CouchDB are decentralized from their hart, which helps us when it comes to scaling up and down, or building federal application architectures. 182 | 183 | 184 | © 2020 Johannes J. Schmidt 185 | -------------------------------------------------------------------------------- /couchdb-setup/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:focal 2 | 3 | RUN apt-get update && DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt-get install -y curl 4 | 5 | COPY ./couchdb-setup.sh /usr/local/bin/couchdb-setup 6 | 7 | CMD ["/usr/local/bin/couchdb-setup", "http://admin:admin@couchdb:5984"] 8 | -------------------------------------------------------------------------------- /couchdb-setup/README.md: -------------------------------------------------------------------------------- 1 | # Setup CouchDB 2 | Provision a new CouchDB server. 3 | 4 | The script [couchdb-setup.sh](couchdb-setup.sh) runs several idempotent curl commands against CouchDB. 5 | 6 | 1. Wait for CouchDB to be ready by querying welcome endpoint in a loop 7 | 1. Setup the cluster as single node, usind `_cluster_setup` API 8 | 1. Enable global changes feed and creating the `_global_changes` database 9 | 1. Configure `_users` db security object (and enable it in config) to make public signup possible 10 | 1. Enable and configure CORS 11 | 1. Increase session timeout 12 | 13 | For example, to configure the global changes feed, we issue curl request like this: 14 | ```bash 15 | curl -XPUT --silent "$COUCHDB_URL/_global_changes" 16 | curl -XPUT --silent "$COUCHDB_URL/_node/nonode@nohost/_config/global_changes/update_db" -d '"true"' 17 | ``` 18 | 19 | ## Dependencies 20 | Setup CouchDB depends on curl. 21 | 22 | 23 | ## Test 24 | You can manually run the script like so: 25 | ```sh 26 | ./couchdb-setup.sh http://admin:admin@localhost:5984 27 | ``` 28 | 29 | 30 | ## TODO: Username Validation 31 | Since we use usernames as database names as well as for Git directory names we will need to strengthen username validation. This will be done by creating another design document in the `_users` database with a validation doc function like this: 32 | ```js 33 | function (newDoc, oldDoc, userCtx, secObj) { 34 | if (!newDoc.name.match(/^[a-z]{3,32}$/)) { 35 | throw({ forbidden: 'doc.name must consist of 3-32 lowercase letters a-z.' }) 36 | } 37 | } 38 | ``` 39 | 40 | 41 | ## Docker 42 | A [Dockerfile](Dockerfile) installs dependencies, runs the script in a docker container and exists afterwards. 43 | -------------------------------------------------------------------------------- /couchdb-setup/couchdb-setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Setup CouchDB 4 | 5 | # Dependencies: 6 | # * curl 7 | 8 | # Usage: 9 | # ./couchdb-setup.sh COUCHDB_URL 10 | # eg: ./setup-couchdb.sh http://admin:admin@localhost:5984 11 | 12 | COUCHDB_URL=$1 13 | 14 | echo "CouchDB Setup started..." 15 | 16 | echo "waiting for CouchDB to come up" 17 | until $(curl --output /dev/null --silent --head --fail "$COUCHDB_URL"); do 18 | printf '.' 19 | sleep 1 20 | done 21 | 22 | echo "cluster setup" 23 | # TODO: get username and password from environment variables or args 24 | curl -XPOST --silent "$COUCHDB_URL/_cluster_setup" \ 25 | -d '{"action":"enable_single_node","username":"admin","password":"admin","bind_address":"0.0.0.0","port":5984,"singlenode":true}' \ 26 | -H 'Content-Type:application/json' 27 | 28 | echo "configure global changes feed" 29 | curl -XPUT --silent "$COUCHDB_URL/_global_changes" 30 | curl -XPUT --silent "$COUCHDB_URL/_node/nonode@nohost/_config/global_changes/update_db" -d '"true"' 31 | 32 | echo "configure _users db for public signup" 33 | curl -XPUT --silent "$COUCHDB_URL/_node/nonode@nohost/_config/couchdb/users_db_security_editable" -d '"true"' 34 | curl -XPUT --silent "$COUCHDB_URL/_users/_security" -d '{}' 35 | 36 | echo "configure CORS" 37 | curl -XPUT --silent "$COUCHDB_URL/_node/nonode@nohost/_config/httpd/enable_cors" -d '"true"' 38 | curl -XPUT --silent "$COUCHDB_URL/_node/nonode@nohost/_config/cors/origins" -d '"*"' 39 | curl -XPUT --silent "$COUCHDB_URL/_node/nonode@nohost/_config/cors/credentials" -d '"true"' 40 | curl -XPUT --silent "$COUCHDB_URL/_node/nonode@nohost/_config/cors/methods" -d '"GET, PUT, POST, HEAD, DELETE"' 41 | curl -XPUT --silent "$COUCHDB_URL/_node/nonode@nohost/_config/cors/headers" -d '"accept, authorization, content-type, origin, referer, x-csrf-token"' 42 | 43 | echo "configure session timeout" 44 | curl -XPUT --silent "$COUCHDB_URL/_node/nonode@nohost/_config/couch_httpd_auth/timeout" -d '"86400"' 45 | 46 | echo "CouchDB setup complete." 47 | -------------------------------------------------------------------------------- /couchdb-worker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:focal 2 | 3 | RUN apt-get update && DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt-get install -y \ 4 | git-core \ 5 | curl \ 6 | jq 7 | 8 | COPY ./ /opt/couchdb-worker/ 9 | RUN chown -R www-data:www-data /opt/couchdb-worker 10 | 11 | USER www-data 12 | 13 | CMD [ "/opt/couchdb-worker/couchdb-worker.sh", "http://admin:admin@couchdb:5984", "/var/www/git" ] 14 | -------------------------------------------------------------------------------- /couchdb-worker/README.md: -------------------------------------------------------------------------------- 1 | # CouchDB Worker 2 | The worker listens to CouchDB changes feeds and reacts on specific changes. 3 | 4 | Technically speaking we listen to the global `_db_updates` feed (we have enabled that feature above). Whenever there is a change in one of the databases we will receive a notification containing the information which database has changed. 5 | Now we query that database for its specific changes (and remember the update seq since we queried last time and start with that). 6 | 7 | This is a Bash implementation, using the UNIX pipeline. The pipeline is designed to handle large amount of data fast. The bash scripts can be replaced by optimized ones one by one. I'll implement them in Rust, if I will find the time. 8 | 9 | 10 | ## Requirements 11 | All the scripts are using 12 | 13 | * curl 14 | * [jq](https://stedolan.github.io/jq/) 15 | 16 | 17 | ## Global Changes Feed 18 | The script [global-changes-feed.sh](global-changes-feed.sh) queries CouchDB for changes and outputs a JSON object to stdout. 19 | 20 | You can run the script like this: 21 | ```sh 22 | ./global-changes-feed.sh http://admin:admin@localhost:5984 23 | ``` 24 | 25 | and it witll spit out a JSON line for each change: 26 | ```json 27 | {"db_name":"mydb","doc":{"_id":"mydoc","_rev":"1-a61a00dff5e02add41819138aba3282d","foo":"bar"} 28 | ``` 29 | 30 | 31 | ## Workers 32 | Now that we have a global changes stream we can pass it to our workers. Currently we have two workers. They are stitched together in the pipeline, we'll come to that part above. 33 | 34 | 35 | ### Provision User Databases 36 | Although there is the `couch_peruser` plugin we would like to provide nice urls to the user and therefore decided to implement it here on our own, since we already have the infrastructure setup at hand. For every document change in `_users` database we will create a database for that user (with appropriate permissions), named after the user. We note that down in the user document. 37 | 38 | 1. create user database 39 | 1. configure user database security 40 | 1. update user doc with `provisionedAt` timestamp 41 | 42 | This is handled by the worker script [provision-users.sh](workers/provision-users.sh). The output of the script is again a JSON containing the individual responses for each step. 43 | 44 | 45 | ### Provision User Repositories 46 | The user can create repository requests in their database. For each of such request, the worker will initialize an empty Git repository and configures its hook: 47 | 48 | 1. Initialize a bare repository at `/var/www/git//.git` 49 | 2. Install the Hook to `/var/www/git//.git/hooks/post-receive` 50 | 1. Update repo doc with `provisionedAt` stamp 51 | 52 | Finally, the creation of the repo is noted down in the repo doc, like so: 53 | 54 | ```json 55 | { 56 | "_id": "repo:my-shiny-repository", 57 | "requestedAt": "2020-04-01T09:37:24.405Z", 58 | "provisionedAt": "2020-04-01T09:37:25.290Z" 59 | } 60 | ``` 61 | 62 | Have a look at the worker script [provision-repos.sh](workers/provision-repos.sh) to see how this is handled. 63 | 64 | 65 | ## Pipeline 66 | Last but not least we need to wind our global changes feed and the workers together. This is done with the help of our best friend T: 67 | 68 | ```bash 69 | ./global-changes-feed.sh "$1" \ 70 | | tee >(./workers/provision-users.sh "$1") \ 71 | | tee >(./workers/provision-repos.sh "$1" "$2") 72 | ``` 73 | 74 | Nothing more does [couchdb-worker.sh](couchdb-worker.sh). 75 | 76 | 77 | ## Global Changes Feed Details 78 | CouchDB itself does not provide a global changes feed. Instead, a `_db_updates` feed tells about changes in each database: 79 | 80 | ```sh 81 | curl http://admin:admin@localhost:5984/_db_updates 82 | ``` 83 | 84 | It responds with a JSON like this: 85 | ```json 86 | { 87 | "results": [ 88 | { 89 | "db_name": "_users", 90 | "type": "updated", 91 | "seq": "3-g1AAAACLeJzLYWBgYMpgTmHgzcvPy09JdcjLz8gvLskBCScyJNX___..." 92 | } 93 | ] 94 | } 95 | ``` 96 | 97 | The individual changes are then queried again against the `dbname/_changes` API: 98 | 99 | ```sh 100 | curl http://admin:admin@localhost:5984/_users/_changes 101 | ``` 102 | 103 | and we get detailed information about that change: 104 | 105 | ```json 106 | { 107 | "results": [ 108 | { 109 | "seq": "2-g1AAAACHeJzLYWBgYMpgTmHgzcvPy09JdcjLz8gvLskBCScyJNX___...", 110 | "id": "org.couchdb.user:eva", 111 | "changes": [ 112 | { 113 | "rev": "1-753ae0157a8b1a22339f3c0ef4f1bf19" 114 | } 115 | ], 116 | "doc": { 117 | "_id": "org.couchdb.user:eva", 118 | "_rev": "1-753ae0157a8b1a22339f3c0ef4f1bf19", 119 | "type": "user", 120 | "name": "eva" 121 | } 122 | } 123 | ] 124 | } 125 | ``` 126 | (Note: I abbreviated the doc in this example) 127 | 128 | The global-changes-feed script handles all that above and also manages state, that is it keeps track of the update sequence, to only query for new information. 129 | 130 | 131 | ## Docker 132 | A [Dockerfile](Dockerfile) installs the dependencies and runs the script in a docker container. 133 | -------------------------------------------------------------------------------- /couchdb-worker/couchdb-worker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Start Worker Pipeline 4 | # - listen to global changes feed 5 | # - pipe changes feed to provision users 6 | # - pipe changes feed to provision repos 7 | 8 | COUCHDB_URL=$1 9 | script_path=$(dirname "$0") 10 | 11 | echo "CouchDB Worker started..." 12 | 13 | echo "waiting for CouchDB to come up" 14 | until $(curl --output /dev/null --silent --head --fail "$COUCHDB_URL"); do 15 | printf '.' 16 | sleep 1 17 | done 18 | 19 | $script_path/global-changes-feed.sh "$COUCHDB_URL" \ 20 | | tee >($script_path/workers/provision-users.sh "$COUCHDB_URL") \ 21 | | tee >($script_path/workers/provision-repos.sh "$COUCHDB_URL" "$2") 22 | -------------------------------------------------------------------------------- /couchdb-worker/global-changes-feed.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # CouchDB global changes feed 4 | # outputs a JSON line to stdout like `{ "db_name": "mydb", "doc": { "_id": "doc_id" } }` for each change 5 | 6 | # Requirements: 7 | # * curl 8 | # * jq 9 | 10 | # Usage: 11 | # ./global-changes-feed.sh COUCHDB_URL 12 | # eg: ./global-changes-feed.sh http://localhost:5984 13 | 14 | COUCHDB_URL=$1 15 | 16 | declare -A update_seqs_per_db 17 | 18 | >&2 echo "Listening to changes on $COUCHDB_URL.." 19 | 20 | while : 21 | do 22 | if [ $last_seq ] 23 | then 24 | db_updates=$(curl --silent "$COUCHDB_URL/_db_updates?feed=longpoll&since=$last_seq") 25 | else 26 | db_updates=$(curl --silent "$COUCHDB_URL/_db_updates?feed=longpoll") 27 | fi 28 | 29 | last_seq=$(echo "$db_updates" | jq -r '.last_seq') 30 | db_changes=$(echo "$db_updates" | jq -c '.results[]') 31 | 32 | for db_change in $db_changes ; do 33 | dbname=$(echo "$db_change" | jq -r '.db_name') 34 | if [ "$dbname" != "_dbs" ] 35 | then 36 | >&2 echo "Found changes on db $dbname" 37 | 38 | since=${update_seqs_per_db[$dbname]} 39 | if [ $since ] 40 | then 41 | >&2 echo "Requesting changes for db $dbname since $since" 42 | changes=$(curl --silent "$COUCHDB_URL/$dbname/_changes?include_docs=true&since=$since") 43 | else 44 | >&2 echo "Requesting changes for db $dbname since the beginning" 45 | changes=$(curl --silent "$COUCHDB_URL/$dbname/_changes?include_docs=true") 46 | fi 47 | 48 | >&2 echo "Found changes on db $dbname: $changes" 49 | 50 | update_seqs_per_db["$dbname"]=$(echo "$changes" | jq -r '.last_seq') 51 | 52 | # build change object with db name and filter out design documents 53 | echo "$changes" \ 54 | | jq -c "{ db_name: \"$dbname\", doc: .results[].doc }" \ 55 | | jq -c 'select( .doc._id | contains("_design/") | not)' 56 | fi 57 | done 58 | done 59 | -------------------------------------------------------------------------------- /couchdb-worker/workers/provision-repos.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Provision Repos 4 | # - create repository 5 | # - setup hook 6 | # - update repo doc with `provisionedAt` stamp 7 | 8 | COUCH=$1 9 | GITDIR=$2 10 | 11 | >&2 echo "Creating repos on $COUCH.." 12 | 13 | while read line; do 14 | dbname=$(echo "$line" | jq -r '.db_name' 2>/dev/null) 15 | if [[ "$dbname" =~ ^[a-z]+ ]] 16 | then 17 | id=$(echo "$line" | jq -r '.doc._id') 18 | t1=$(echo "$id" | cut -d ':' -f1) 19 | v1=$(echo "$id" | cut -d ':' -f2) 20 | t2=$(echo "$id" | cut -d ':' -f3) 21 | # restrict to repo types 22 | if [ "$t1" = "repo" ] 23 | then 24 | # ignore nested types 25 | if [ -z "$t2" ] 26 | then 27 | # skip if user database already provisioned 28 | already_provisioned=$(echo "$line" | jq -r '.doc.provisionedAt') 29 | if [ "$already_provisioned" = "null" ] 30 | then 31 | repofilename="$GITDIR/$dbname/$v1.git" 32 | >&2 echo "Provisioning repo $repofilename" 33 | 34 | create_repo_response=$( 35 | mkdir -p "$repofilename" && cd "$repofilename" && git init --bare 36 | ) 37 | cat << EOF > "$repofilename/hooks/post-receive" 38 | #!/bin/bash 39 | /usr/local/bin/couchdb-git-hook <&0 40 | EOF 41 | chmod +x "$repofilename/hooks/post-receive" 42 | now=$(date --iso-8601=seconds) 43 | doc=$(echo "$line" | jq '.doc' | jq ".provisionedAt = \"$now\"") 44 | update_repo_doc_response=$( 45 | echo "$doc" | \ 46 | curl --silent -XPUT "$COUCH/$dbname/$id" \ 47 | -d @- \ 48 | -H "Content-Type:application/json" 49 | ) 50 | echo "{}" | jq -c "{ type: \"provision-repo\", user: \"$dbname\", repo: \"$v1\", create: \"$create_repo_response\", doc: $update_repo_doc_response }" 51 | fi 52 | fi 53 | fi 54 | fi 55 | done 56 | -------------------------------------------------------------------------------- /couchdb-worker/workers/provision-users.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Provision Users 4 | # - create user database 5 | # - configure user database security 6 | # - update user doc with `provisionedAt` timestamp 7 | 8 | COUCHDB_URL=$1 9 | 10 | >&2 echo "Creating user dbs on $COUCHDB_URL.." 11 | 12 | while read line; do 13 | dbname=$(echo "$line" | jq -r '.db_name' 2>/dev/null) 14 | # only operate on _users database 15 | if [ "$dbname" = "_users" ] 16 | then 17 | id=$(echo "$line" | jq -r '.doc._id') 18 | # ignore _design and _local documents 19 | if [[ "$id" != "_*" ]] 20 | then 21 | already_provisioned=$(echo "$line" | jq -r '.doc.provisionedAt') 22 | # skip if user database already provisioned 23 | if [ "$already_provisioned" = "null" ] 24 | then 25 | username=$(echo "$line" | jq -r '.doc.name') 26 | >&2 echo "Provisioning user $username" 27 | 28 | create_db_response=$(curl --silent -XPUT "$COUCHDB_URL/$username") 29 | update_security_response=$( 30 | curl --silent -XPUT "$COUCHDB_URL/$username/_security" \ 31 | -d "{\"members\":{\"roles\":[\"_admin\"],\"names\":[\"$username\"]},\"admins\":{\"roles\":[\"_admin\"],\"names\":[\"$username\"]}}" \ 32 | -H "Content-Type:application/json") 33 | now=$(date --iso-8601=seconds) 34 | doc=$(echo "$line" | jq '.doc' | jq ".provisionedAt = \"$now\"") 35 | update_user_doc_response=$( 36 | echo "$doc" | \ 37 | curl --silent -XPUT "$COUCHDB_URL/_users/org.couchdb.user:$username" \ 38 | -d @- \ 39 | -H "Content-Type:application/json" 40 | ) 41 | echo "{}" | jq -c "{ type: \"provision-user\", user: \"$username\", create: $create_db_response, security: $update_security_response, doc: $update_user_doc_response }" 42 | fi 43 | fi 44 | fi 45 | done 46 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2.0' 2 | services: 3 | couchdb: 4 | image: apache/couchdb:latest 5 | ports: 6 | - 5984:5984 7 | environment: 8 | - 'COUCHDB_USER=admin' 9 | - 'COUCHDB_PASSWORD=admin' 10 | 11 | couchdb-setup: 12 | build: ./couchdb-setup 13 | depends_on: 14 | - couchdb 15 | 16 | couchdb-worker: 17 | build: ./couchdb-worker 18 | depends_on: 19 | - couchdb-setup 20 | volumes: 21 | - git-data:/var/www/git 22 | 23 | git-server: 24 | build: ./git-server 25 | depends_on: 26 | - couchdb-worker 27 | volumes: 28 | - git-data:/var/www/git 29 | ports: 30 | - 8080:80 31 | 32 | webapp: 33 | build: ./webapp 34 | depends_on: 35 | - git-server 36 | ports: 37 | - 3000:80 38 | 39 | volumes: 40 | git-data: 41 | -------------------------------------------------------------------------------- /git-server/000-default.conf: -------------------------------------------------------------------------------- 1 | 2 | ServerAdmin webmaster@localhost 3 | DocumentRoot /var/www/html 4 | ErrorLog ${APACHE_LOG_DIR}/error.log 5 | CustomLog ${APACHE_LOG_DIR}/access.log combined 6 | 7 | SetEnv GIT_PROJECT_ROOT /var/www/git 8 | SetEnv GIT_HTTP_EXPORT_ALL 9 | ScriptAlias / /usr/lib/git-core/git-http-backend/ 10 | AliasMatch ^/(.*/objects/[0-9a-f]{2}/[0-9a-f]{38})$ /var/www/git/$1 11 | AliasMatch ^/(.*/objects/pack/pack-[0-9a-f]{40}.(pack|idx))$ /var/www/git/$1 12 | 13 | 14 | Options +ExecCGI +SymLinksIfOwnerMatch 15 | # Order allow,deny 16 | Require all granted 17 | 18 | 19 | DefineExternalAuth couchdb environment "/usr/local/bin/couchdb-auth http://localhost:5984" 20 | 21 | [^/]+)/"> 22 | AuthType Basic 23 | AuthName "Git Relax" 24 | AuthBasicProvider external 25 | AuthExternal couchdb 26 | Require user %{env:MATCH_USERNAME} 27 | 28 | 29 | 30 | # vim: syntax=apache ts=4 sw=4 sts=4 sr noet 31 | -------------------------------------------------------------------------------- /git-server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:focal 2 | 3 | RUN apt-get update && DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt-get install -y \ 4 | apache2 \ 5 | libapache2-mod-authnz-external \ 6 | curl \ 7 | git-core 8 | 9 | RUN a2enmod cgi 10 | 11 | COPY ./000-default.conf /etc/apache2/sites-available/000-default.conf 12 | RUN sed -i -e 's/localhost:5984/couchdb:5984/' /etc/apache2/sites-available/000-default.conf 13 | 14 | COPY ./couchdb-auth/couchdb-auth.sh /usr/local/bin/couchdb-auth 15 | RUN chmod +x /usr/local/bin/couchdb-auth 16 | 17 | COPY ./couchdb-git-hook/couchdb-git-hook.sh /usr/local/bin/couchdb-git-hook 18 | RUN sed -i -e 's/localhost:5984/couchdb:5984/' /usr/local/bin/couchdb-git-hook 19 | RUN chmod +x /usr/local/bin/couchdb-git-hook 20 | 21 | RUN mkdir -p /var/www/git/ 22 | RUN chown www-data:www-data -R /var/www/git/ 23 | 24 | EXPOSE 80 25 | 26 | CMD ["/usr/sbin/apache2ctl", "-DFOREGROUND"] 27 | -------------------------------------------------------------------------------- /git-server/README.md: -------------------------------------------------------------------------------- 1 | # Git Server 2 | Git comes with a CGI script to serve a repo over http. See https://git-scm.com/book/pl/v2/Git-on-the-Server-Smart-HTTP. 3 | 4 | ```conf 5 | SetEnv GIT_PROJECT_ROOT /var/www/git 6 | SetEnv GIT_HTTP_EXPORT_ALL 7 | ScriptAlias / /usr/lib/git-core/git-http-backend/ 8 | 9 | Options +ExecCGI +SymLinksIfOwnerMatch 10 | Require all granted 11 | 12 | ``` 13 | 14 | We finally restrict access to the users Git home: 15 | 16 | ```conf 17 | [^/]+)/"> 18 | Require user %{env:MATCH_USERNAME} 19 | 20 | ``` 21 | 22 | Combining that with our [CouchDB authentication](couchdb-auth), we'll get an Apache config like so: 23 | 24 | ```conf 25 | 26 | # Configure git http backend 27 | SetEnv GIT_PROJECT_ROOT /var/www/git 28 | SetEnv GIT_HTTP_EXPORT_ALL 29 | ScriptAlias / /usr/lib/git-core/git-http-backend/ 30 | AliasMatch ^/(.*/objects/[0-9a-f]{2}/[0-9a-f]{38})$ /var/www/git/$1 31 | AliasMatch ^/(.*/objects/pack/pack-[0-9a-f]{40}.(pack|idx))$ /var/www/git/$1 32 | 33 | 34 | Options +ExecCGI +SymLinksIfOwnerMatch 35 | Require all granted 36 | 37 | 38 | DefineExternalAuth couchdb environment "/usr/local/bin/couchdb-auth http://localhost:5984" 39 | 40 | [^/]+)/"> 41 | AuthType Basic 42 | AuthName "Git Relax" 43 | AuthBasicProvider external 44 | AuthExternal couchdb 45 | Require user %{env:MATCH_USERNAME} 46 | 47 | 48 | ``` 49 | 50 | Now we can serve our Git repositories via http. 51 | 52 | 53 | ## Docker 54 | A [Dockerfile](Dockerfile) installs the dependencies, configures and runs Apache2 in a docker container. 55 | -------------------------------------------------------------------------------- /git-server/couchdb-auth/README.md: -------------------------------------------------------------------------------- 1 | # CouchDB Auth 2 | Authenticate against CouchDB `_session` endpoint. 3 | 4 | The script [couchdb-auth.sh](couchdb-auth.sh) is configured in Apache2 using an external `AuthBasicProvider`: 5 | 6 | ```conf 7 | DefineExternalAuth couchdb environment "/usr/local/bin/couchdb-auth http://localhost:5984" 8 | 9 | 10 | AuthType Basic 11 | AuthName "Git Relax" 12 | AuthBasicProvider external 13 | AuthExternal couchdb 14 | Require valid-user 15 | 16 | ``` 17 | 18 | Username and password are passed over via environment variables. Future versions should use the more secure stdin method, though. 19 | 20 | The script makes an authenticated query against the `_session` endpoint, like so: 21 | ```sh 22 | curl -u "${USER}:${PASS}" http://localhost:5984/_session 23 | ``` 24 | and only if the response is a `200` the script exists with `0` which tells Apache2 a successful login attempt. 25 | 26 | Some useful links for reference: 27 | * https://blog.g3rt.nl/custom-http-basic-authentication-apache.html 28 | * https://github.com/haegar/mod-auth-external/wiki/AuthHowTo 29 | * https://unix.stackexchange.com/questions/145571/apache-authorization-for-the-allowed-users 30 | 31 | 32 | ## Requirements 33 | CouchDB Auth depends on curl. 34 | 35 | 36 | ## Test 37 | You can manually run the script like so: 38 | ```sh 39 | USER=admin PASS=admin ./couchdb-auth.sh http://localhost:5984 40 | ``` 41 | 42 | 43 | ## TODO 44 | - use stdin method instead of environment 45 | - support session auth via COOKIE 46 | -------------------------------------------------------------------------------- /git-server/couchdb-auth/couchdb-auth.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # External authenticator script for Apache2. 4 | 5 | # Requirements: 6 | # * curl 7 | 8 | # Usage: 9 | # USER=USERNAME PASS=PASSWORD ./couchdb-auth.sh COUCHDB_URL 10 | # eg: USER=admin PASS=admin ./couchdb-auth.sh http://localhost:5984 11 | 12 | COUCHDB_URL=$1 13 | 14 | # Make query with basic auth against CouchDB /_session endpoint. If response code is 200 you are authorized 15 | status_code=$(curl --write-out %{http_code} --silent --output /dev/null -u "${USER}:${PASS}" "$COUCHDB_URL/_session") 16 | 17 | echo "[notice] $(date) authenticating ${USER}: ${status_code}" 18 | 19 | if [[ "$status_code" -ne 200 ]] ; then 20 | exit 1 21 | else 22 | exit 0 23 | fi 24 | -------------------------------------------------------------------------------- /git-server/couchdb-git-hook/README.md: -------------------------------------------------------------------------------- 1 | # CouchDB Git Hook 2 | This hook is installed on user repositories as a `post-receive` hook. The hooks receives information about pushed refs via stdin, like this: 3 | 4 | ``` 5 | # ` SP SP LF` 6 | # 0000000000000000000000000000000000000000 f2a4dfdcbb970b22aca260144ac294c31a41a832 refs/heads/master 7 | # 0000000000000000000000000000000000000000 6147b545c5c21473dbd4327fcf4121b99fe4dcd2 refs/heads/mybranch 8 | ``` 9 | 10 | See https://git-scm.com/docs/githooks#post-receive for reference. 11 | 12 | After each receive event, it creates a CouchDB document in the users database. This is a push to `master` branch: 13 | ```json 14 | { 15 | "_id": "repo:myrepo:branch:master:ref:2fec5028e492bee6395d77107ae0debd3dd855f2", 16 | "_rev": "1-967a00dff5e02add41819138abb3284d" 17 | } 18 | ``` 19 | 20 | And this a push to `mybranch`: 21 | ```json 22 | { 23 | "_id": "repo:myrepo:branch:mybranch:ref:6147b545c5c21473dbd4327fcf4121b99fe4dcd2", 24 | "_rev": "1-967a00dff5e02add41819138abb3284d" 25 | } 26 | ``` 27 | 28 | The hook can either be copied over or symlinked into each user repository, or called like this: 29 | `hooks/post-receive`: 30 | ``` 31 | #!/bin/bash 32 | /usr/local/bin/couchdb-git-hook <&0 33 | ``` 34 | 35 | ## TODO: 36 | Currently the CouchDB url is hardcoded. We should find a uniform way how to handle such configuration. 37 | 38 | 39 | ## Testing 40 | You can invoke an test the script manually like so: 41 | ```sh 42 | echo "0000000000000000000000000000000000000000 f2a4dfdcbb970b22aca260144ac294c31a41a832 refs/heads/master" | ./couchdb-git-hook.sh 43 | ``` 44 | -------------------------------------------------------------------------------- /git-server/couchdb-git-hook/couchdb-git-hook.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Git post receive hook to sync push events to CouchDB. 4 | 5 | # Requirements: 6 | # * curl 7 | 8 | COUCHDB_URL=http://admin:admin@localhost:5984 9 | 10 | repofilename=$(pwd) 11 | 12 | reponame=$(basename $repofilename '.git') 13 | repodirname=$(dirname $repofilename) 14 | username=$(basename $repodirname) 15 | 16 | # read receive ref info from stdin: 17 | while read line 18 | do 19 | previous=$(echo "$line" | cut -d " " -f1) 20 | current=$(echo "$line" | cut -d " " -f2) 21 | ref=$(echo "$line" | cut -d " " -f3) 22 | branchname=$(basename "$ref") 23 | 24 | id="repo:$reponame:branch:$branchname:ref:$current" 25 | 26 | payload="{\"_id\":\"$id\"}" 27 | 28 | # TODO: use credentials hopefully (or not hähä) available as environment variable 29 | curl -s -XPOST "$COUCHDB_URL/$username" -d "$payload" -H 'Content-Type:application/json' 30 | done < /dev/stdin 31 | 32 | -------------------------------------------------------------------------------- /webapp/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM httpd:2.4 2 | 3 | COPY index.html /usr/local/apache2/htdocs/ 4 | COPY style.css /usr/local/apache2/htdocs/ 5 | COPY app.js /usr/local/apache2/htdocs/ 6 | COPY couch.js /usr/local/apache2/htdocs/ 7 | -------------------------------------------------------------------------------- /webapp/README.md: -------------------------------------------------------------------------------- 1 | # Webapp 2 | Provide exemplary user interface for Git Relax: 3 | 4 | * Signup & login 5 | * Create repository requests 6 | * Display list of repos and their status 7 | * Display activity stream 8 | 9 | The app listens to `/_changes` feed and updates ui on changes. 10 | 11 | 12 | ## Next Steps 13 | We could use **isomorphic-git to access and manipulate our repos** - list change log, repo contents, manipulate files with CodeMirror or ProseMirror or whatever, make commits and so on. 14 | 15 | And we can use **PouchDB to have this completely offline**. We can even create repo requests, create the repo locally. Then, once the request has been synced and processed, we can push the local repo. 16 | 17 | Since isomorphic-git is not good at merging atm we can further implement a **server side automatic pull request resolver**. Each client will then operate on their own branch and the worker operates on the git repo, merges that branch to master and vice versa. Conflicts are marked for user resolve and can be displayed in a webapp. 18 | 19 | Since this is all standard technology (Apache, HTTP, Web, Git, CouchDB), we can **implement offline sync natively on almost any platform**. 20 | 21 | 22 | ## Docker 23 | A [Dockerfile](Dockerfile), provided for convenience, installs and runs the app on a Apache2 webserver. 24 | 25 | 26 | ## Development 27 | During development I usually just spin up a local server: 28 | ```sh 29 | http-server webapp 30 | ``` 31 | and have my code change take effect immediately. 32 | -------------------------------------------------------------------------------- /webapp/app.js: -------------------------------------------------------------------------------- 1 | import * as couch from '/couch.js' 2 | 3 | const RepositoriesListEntry = (properties, doc) => { 4 | const template = properties.templates.repositoriesListEntry 5 | const clone = document.importNode(template.content, true) 6 | const li = clone.querySelector('li') 7 | li.innerText = doc.text 8 | li.setAttribute('data-id', doc.id) 9 | li.className = doc.provisionedAt ? 'provisioned' : 'requested' 10 | return clone 11 | } 12 | 13 | const ActivitiesListEntry = (properties, doc) => { 14 | const template = properties.templates.activitiesListEntry 15 | const clone = document.importNode(template.content, true) 16 | const li = clone.querySelector('li') 17 | li.innerText = doc.text 18 | return clone 19 | } 20 | 21 | const Dashboard = async (properties, username) => { 22 | const template = properties.templates.dashboard 23 | const clone = document.importNode(template.content, true) 24 | 25 | clone.querySelector('h2').innerText = `Hi ${username},` 26 | 27 | const form = clone.querySelector('form') 28 | const reponameInput = clone.querySelector('input[name=reponame]') 29 | 30 | form.onsubmit = async e => { 31 | e.preventDefault() 32 | const name = reponameInput.value 33 | const response = await couch.createRepoRequest(username, name) 34 | if (response.ok) { 35 | reponameInput.value = '' 36 | } 37 | } 38 | 39 | const repositoriesList = clone.querySelector('#repositories-list') 40 | const activitiesList = clone.querySelector('#activities-list') 41 | const repositoriesListElements = {} 42 | 43 | couch.getChanges(username, change => { 44 | const parts = change.id.split(':') 45 | const [type, name, subtype, subname, subsubtype, subsubname] = parts 46 | 47 | if (type === 'repo' && parts.length === 2) { 48 | const entry = RepositoriesListEntry(properties, { 49 | id: change.id, 50 | text: `http://localhost:8080/${username}/${name}.git`, 51 | provisionedAt: change.doc.provisionedAt 52 | }) 53 | const existing = repositoriesList.querySelector(`[data-id="${change.id}"]`) 54 | if (existing) { 55 | repositoriesList.replaceChild(entry, existing) 56 | } else { 57 | repositoriesList.prepend(entry) 58 | } 59 | } 60 | if (type === 'repo' && subtype === 'branch' && subsubtype === 'ref' && parts.length === 6) { 61 | const entry = ActivitiesListEntry(properties, { 62 | text: `pushed to repo ${name} on branch ${subname} ref ${subsubname.slice(0, 7)}` 63 | }) 64 | activitiesList.prepend(entry) 65 | } 66 | }) 67 | 68 | const logoutLink = clone.querySelector('a') 69 | logoutLink.onclick = async e => { 70 | e.preventDefault() 71 | const response = await couch.deleteSession() 72 | if (response.ok) { 73 | LoginForm(properties) 74 | } 75 | } 76 | 77 | properties.elements.article.innerHTML = '' 78 | properties.elements.article.appendChild(clone) 79 | } 80 | 81 | const SignupForm = properties => { 82 | const template = properties.templates.signupForm 83 | const clone = document.importNode(template.content, true) 84 | 85 | const form = clone.querySelector('form') 86 | const usernameInput = clone.querySelector('input[name=username]') 87 | const passwordInput = clone.querySelector('input[name=password]') 88 | 89 | form.onsubmit = async e => { 90 | e.preventDefault() 91 | const name = usernameInput.value 92 | const password = passwordInput.value 93 | const response = await couch.createUser(name, password) 94 | if (response.ok) { 95 | await LoginForm(properties) 96 | } 97 | } 98 | 99 | properties.elements.article.innerHTML = '' 100 | properties.elements.article.appendChild(clone) 101 | } 102 | 103 | const LoginForm = properties => { 104 | const template = properties.templates.loginForm 105 | const clone = document.importNode(template.content, true) 106 | 107 | const form = clone.querySelector('form') 108 | const usernameInput = clone.querySelector('input[name=username]') 109 | const passwordInput = clone.querySelector('input[name=password]') 110 | 111 | form.onsubmit = async e => { 112 | e.preventDefault() 113 | const name = usernameInput.value 114 | const password = passwordInput.value 115 | const response = await couch.createSession(name, password) 116 | if (response.ok) { 117 | await Dashboard(properties, name) 118 | } 119 | } 120 | 121 | const signupLink = clone.querySelector('a') 122 | signupLink.onclick = e => { 123 | e.preventDefault() 124 | SignupForm(properties) 125 | } 126 | 127 | properties.elements.article.innerHTML = '' 128 | properties.elements.article.appendChild(clone) 129 | } 130 | 131 | export const App = async properties => { 132 | const response = await couch.getSession() 133 | if (response.ok && response.userCtx.name) { 134 | Dashboard(properties, response.userCtx.name) 135 | } else { 136 | LoginForm(properties) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /webapp/couch.js: -------------------------------------------------------------------------------- 1 | const couchdbUrl = 'http://localhost:5984' 2 | 3 | export const getSession = async () => { 4 | const response = await fetch(`${couchdbUrl}/_session`, { 5 | method: 'GET', 6 | mode: 'cors', 7 | cache: 'no-cache', 8 | headers: { 9 | 'Content-Type': 'application/json' 10 | }, 11 | credentials: 'include' 12 | }) 13 | return await response.json() 14 | } 15 | 16 | export const createSession = async (name, password) => { 17 | const response = await fetch(`${couchdbUrl}/_session`, { 18 | method: 'POST', 19 | mode: 'cors', 20 | cache: 'no-cache', 21 | headers: { 22 | 'Content-Type': 'application/json' 23 | }, 24 | credentials: 'include', 25 | body: JSON.stringify({ name, password }) 26 | }) 27 | return await response.json() 28 | } 29 | 30 | export const deleteSession = async () => { 31 | const response = await fetch(`${couchdbUrl}/_session`, { 32 | method: 'DELETE', 33 | mode: 'cors', 34 | cache: 'no-cache', 35 | headers: { 36 | 'Content-Type': 'application/json' 37 | }, 38 | credentials: 'include' 39 | }) 40 | return await response.json() 41 | } 42 | 43 | export const createUser = async (name, password) => { 44 | const _id = `org.couchdb.user:${name}` 45 | const response = await fetch(`${couchdbUrl}/_users/${_id}`, { 46 | method: 'PUT', 47 | mode: 'cors', 48 | cache: 'no-cache', 49 | headers: { 50 | 'Content-Type': 'application/json' 51 | }, 52 | body: JSON.stringify({ 53 | _id, 54 | name, 55 | password, 56 | type: 'user', 57 | roles: [] 58 | }) 59 | }) 60 | return await response.json() 61 | } 62 | 63 | export const getChanges = async (username, onchange, since) => { 64 | const response = await fetch(`${couchdbUrl}/${username}/_changes?feed=longpoll&include_docs=true${since ? '&since=' + since : ''}`, { 65 | method: 'GET', 66 | mode: 'cors', 67 | cache: 'no-cache', 68 | headers: { 69 | 'Content-Type': 'application/json' 70 | }, 71 | credentials: 'include' 72 | }) 73 | const json = await response.json() 74 | for (const result of json.results) { 75 | onchange(result) 76 | } 77 | getChanges(username, onchange, json.last_seq) 78 | } 79 | 80 | export const createRepoRequest = async (username, name) => { 81 | const _id = `repo:${name}` 82 | const requestedAt = new Date() 83 | const response = await fetch(`${couchdbUrl}/${username}/${_id}`, { 84 | method: 'PUT', 85 | mode: 'cors', 86 | cache: 'no-cache', 87 | headers: { 88 | 'Content-Type': 'application/json' 89 | }, 90 | credentials: 'include', 91 | body: JSON.stringify({ 92 | _id, 93 | requestedAt 94 | }) 95 | }) 96 | return await response.json() 97 | } 98 | -------------------------------------------------------------------------------- /webapp/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Git Relax 6 | 7 | 8 | 9 | 10 | 13 |
14 | 15 |
16 |
17 | 30 | 42 | 58 | 61 | 64 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /webapp/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | margin: 1cm 1.618cm; 3 | } 4 | 5 | .requested { 6 | color: red; 7 | } 8 | .provisioned { 9 | color: green; 10 | } 11 | --------------------------------------------------------------------------------