Merge pull request #1785 from zed-industries/auto-deploy-collab

Max Brunsfeld created

Automatically build collab server docker images based on git tags

Change summary

.dockerignore                              | 14 +++
.github/workflows/publish_collab_image.yml | 46 ++++++++++++
Dockerfile                                 |  4 
Dockerfile.migrator                        | 15 ----
Procfile                                   |  2 
crates/collab/Procfile                     |  2 
crates/collab/k8s/manifest.template.yml    |  2 
crates/collab/k8s/migrate.template.yml     |  3 
crates/collab/src/db.rs                    | 69 ++++++++++++++++--
crates/collab/src/main.rs                  | 85 ++++++++++++++++++-----
script/bootstrap                           |  2 
script/bump-app-version                    |  3 
script/bump-collab-version                 |  3 
script/deploy                              | 39 +++-------
script/deploy-migration                    | 46 ++++--------
script/lib/bump-version.sh                 | 39 +++++++++++
script/lib/deploy-helpers.sh               | 40 +++++++++++
script/what-is-deployed                    | 35 +++++++++
18 files changed, 338 insertions(+), 111 deletions(-)

Detailed changes

.dockerignore 🔗

@@ -1,3 +1,11 @@
-/target
-/manifest.yml
-/migrate.yml
+**/target
+zed.xcworkspace
+.DS_Store
+plugins/bin
+script/node_modules
+styles/node_modules
+crates/collab/static/styles.css
+vendor/bin
+assets/themes/*.json
+assets/themes/internal/*.json
+assets/themes/experiments/*.json

.github/workflows/publish_collab_image.yml 🔗

@@ -0,0 +1,46 @@
+name: Publish Collab Server Image
+
+on:
+  push:
+    tags:
+      - collab-v*
+
+env:
+  DOCKER_BUILDKIT: 1
+  DIGITALOCEAN_ACCESS_TOKEN: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
+
+jobs:
+  publish:
+    name: Publish collab server image 
+    runs-on:
+      - self-hosted
+      - deploy
+    steps:
+      - name: Add Rust to the PATH
+        run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
+
+      - name: Sign into DigitalOcean docker registry
+        run: doctl registry login
+
+      - name: Checkout repo
+        uses: actions/checkout@v3
+        with:
+          clean: false
+
+      - name: Check that tag version matches package version
+        run: |
+          set -eu
+          package_version=$(cargo metadata --no-deps --format-version=1 | jq --raw-output '.packages[] | select(.name == "collab") | .version')
+          tag_version=$(echo $GITHUB_REF_NAME | sed -e 's/collab-v//')
+          if [[ $tag_version != $package_version ]]; then
+            echo "collab package version $package_version does not match git tag version $tag_version"
+            exit 1
+          fi
+          echo "Publishing image version: $package_version"
+          echo "COLLAB_VERSION=$package_version" >> $GITHUB_ENV
+
+      - name: Build docker image
+        run: docker build . --tag registry.digitalocean.com/zed/collab:v${COLLAB_VERSION}
+    
+      - name: Publish docker image
+        run: docker push registry.digitalocean.com/zed/collab:v${COLLAB_VERSION}

Dockerfile 🔗

@@ -19,5 +19,7 @@ FROM debian:bullseye-slim as runtime
 RUN apt-get update; \
     apt-get install -y --no-install-recommends libcurl4-openssl-dev ca-certificates
 WORKDIR app
-COPY --from=builder /app/collab /app
+COPY --from=builder /app/collab /app/collab
+COPY --from=builder /app/crates/collab/migrations /app/migrations
+ENV MIGRATIONS_PATH=/app/migrations
 ENTRYPOINT ["/app/collab"]

Dockerfile.migrator 🔗

@@ -1,15 +0,0 @@
-# syntax = docker/dockerfile:1.2
-
-FROM rust:1.64-bullseye as builder
-WORKDIR app
-RUN --mount=type=cache,target=/usr/local/cargo/registry \
-    --mount=type=cache,target=./target \
-    cargo install sqlx-cli --root=/app --target-dir=/app/target --version 0.5.7
-
-FROM debian:bullseye-slim as runtime
-RUN apt-get update; \
-    apt-get install -y --no-install-recommends libssl1.1
-WORKDIR app
-COPY --from=builder /app/bin/sqlx /app
-COPY ./crates/collab/migrations /app/migrations
-ENTRYPOINT ["/app/sqlx", "migrate", "run"]

Procfile 🔗

@@ -1,2 +1,2 @@
 web: cd ../zed.dev && PORT=3000 npx vercel dev
-collab: cd crates/collab && cargo run
+collab: cd crates/collab && cargo run serve

crates/collab/k8s/migrate.template.yml 🔗

@@ -9,7 +9,10 @@ spec:
       restartPolicy: Never
       containers:
         - name: migrator
+          imagePullPolicy: Always
           image: ${ZED_IMAGE_ID}
+          args:
+            - migrate
           env:
             - name: DATABASE_URL
               valueFrom:

crates/collab/src/db.rs 🔗

@@ -6,8 +6,12 @@ use collections::HashMap;
 use futures::StreamExt;
 use serde::{Deserialize, Serialize};
 pub use sqlx::postgres::PgPoolOptions as DbOptions;
-use sqlx::{types::Uuid, FromRow, QueryBuilder};
-use std::{cmp, ops::Range, time::Duration};
+use sqlx::{
+    migrate::{Migrate as _, Migration, MigrationSource},
+    types::Uuid,
+    FromRow, QueryBuilder,
+};
+use std::{cmp, ops::Range, path::Path, time::Duration};
 use time::{OffsetDateTime, PrimitiveDateTime};
 
 #[async_trait]
@@ -173,6 +177,13 @@ pub trait Db: Send + Sync {
     fn as_fake(&self) -> Option<&FakeDb>;
 }
 
+#[cfg(any(test, debug_assertions))]
+pub const DEFAULT_MIGRATIONS_PATH: Option<&'static str> =
+    Some(concat!(env!("CARGO_MANIFEST_DIR"), "/migrations"));
+
+#[cfg(not(any(test, debug_assertions)))]
+pub const DEFAULT_MIGRATIONS_PATH: Option<&'static str> = None;
+
 pub struct PostgresDb {
     pool: sqlx::PgPool,
 }
@@ -187,6 +198,47 @@ impl PostgresDb {
         Ok(Self { pool })
     }
 
+    pub async fn migrate(
+        &self,
+        migrations_path: &Path,
+        ignore_checksum_mismatch: bool,
+    ) -> anyhow::Result<Vec<(Migration, Duration)>> {
+        let migrations = MigrationSource::resolve(migrations_path)
+            .await
+            .map_err(|err| anyhow!("failed to load migrations: {err:?}"))?;
+
+        let mut conn = self.pool.acquire().await?;
+
+        conn.ensure_migrations_table().await?;
+        let applied_migrations: HashMap<_, _> = conn
+            .list_applied_migrations()
+            .await?
+            .into_iter()
+            .map(|m| (m.version, m))
+            .collect();
+
+        let mut new_migrations = Vec::new();
+        for migration in migrations {
+            match applied_migrations.get(&migration.version) {
+                Some(applied_migration) => {
+                    if migration.checksum != applied_migration.checksum && !ignore_checksum_mismatch
+                    {
+                        Err(anyhow!(
+                            "checksum mismatch for applied migration {}",
+                            migration.description
+                        ))?;
+                    }
+                }
+                None => {
+                    let elapsed = conn.apply(&migration).await?;
+                    new_migrations.push((migration, elapsed));
+                }
+            }
+        }
+
+        Ok(new_migrations)
+    }
+
     pub fn fuzzy_like_string(string: &str) -> String {
         let mut result = String::with_capacity(string.len() * 2 + 1);
         for c in string.chars() {
@@ -1763,11 +1815,8 @@ mod test {
     use lazy_static::lazy_static;
     use parking_lot::Mutex;
     use rand::prelude::*;
-    use sqlx::{
-        migrate::{MigrateDatabase, Migrator},
-        Postgres,
-    };
-    use std::{path::Path, sync::Arc};
+    use sqlx::{migrate::MigrateDatabase, Postgres};
+    use std::sync::Arc;
     use util::post_inc;
 
     pub struct FakeDb {
@@ -2430,13 +2479,13 @@ mod test {
             let mut rng = StdRng::from_entropy();
             let name = format!("zed-test-{}", rng.gen::<u128>());
             let url = format!("postgres://postgres@localhost/{}", name);
-            let migrations_path = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/migrations"));
             Postgres::create_database(&url)
                 .await
                 .expect("failed to create test db");
             let db = PostgresDb::new(&url, 5).await.unwrap();
-            let migrator = Migrator::new(migrations_path).await.unwrap();
-            migrator.run(&db.pool).await.unwrap();
+            db.migrate(Path::new(DEFAULT_MIGRATIONS_PATH.unwrap()), false)
+                .await
+                .unwrap();
             Self {
                 db: Some(Arc::new(db)),
                 url,

crates/collab/src/main.rs 🔗

@@ -10,12 +10,15 @@ mod db_tests;
 mod integration_tests;
 
 use crate::rpc::ResultExt as _;
-use axum::{body::Body, Router};
+use anyhow::anyhow;
+use axum::{routing::get, Router};
 use collab::{Error, Result};
 use db::{Db, PostgresDb};
 use serde::Deserialize;
 use std::{
+    env::args,
     net::{SocketAddr, TcpListener},
+    path::PathBuf,
     sync::Arc,
     time::Duration,
 };
@@ -24,6 +27,8 @@ use tracing_log::LogTracer;
 use tracing_subscriber::{filter::EnvFilter, fmt::format::JsonFields, Layer};
 use util::ResultExt;
 
+const VERSION: &'static str = env!("CARGO_PKG_VERSION");
+
 #[derive(Default, Deserialize)]
 pub struct Config {
     pub http_port: u16,
@@ -37,6 +42,12 @@ pub struct Config {
     pub log_json: Option<bool>,
 }
 
+#[derive(Default, Deserialize)]
+pub struct MigrateConfig {
+    pub database_url: String,
+    pub migrations_path: Option<PathBuf>,
+}
+
 pub struct AppState {
     db: Arc<dyn Db>,
     live_kit_client: Option<Arc<dyn live_kit_server::api::Client>>,
@@ -79,28 +90,62 @@ async fn main() -> Result<()> {
         );
     }
 
-    let config = envy::from_env::<Config>().expect("error loading config");
-    init_tracing(&config);
-    let state = AppState::new(config).await?;
-
-    let listener = TcpListener::bind(&format!("0.0.0.0:{}", state.config.http_port))
-        .expect("failed to bind TCP listener");
-    let rpc_server = rpc::Server::new(state.clone(), None);
-
-    rpc_server.start_recording_project_activity(Duration::from_secs(5 * 60), rpc::RealExecutor);
-
-    let app = Router::<Body>::new()
-        .merge(api::routes(rpc_server.clone(), state.clone()))
-        .merge(rpc::routes(rpc_server.clone()));
-
-    axum::Server::from_tcp(listener)?
-        .serve(app.into_make_service_with_connect_info::<SocketAddr>())
-        .with_graceful_shutdown(graceful_shutdown(rpc_server, state))
-        .await?;
-
+    match args().skip(1).next().as_deref() {
+        Some("version") => {
+            println!("collab v{VERSION}");
+        }
+        Some("migrate") => {
+            let config = envy::from_env::<MigrateConfig>().expect("error loading config");
+            let db = PostgresDb::new(&config.database_url, 5).await?;
+
+            let migrations_path = config
+                .migrations_path
+                .as_deref()
+                .or(db::DEFAULT_MIGRATIONS_PATH.map(|s| s.as_ref()))
+                .ok_or_else(|| anyhow!("missing MIGRATIONS_PATH environment variable"))?;
+
+            let migrations = db.migrate(&migrations_path, false).await?;
+            for (migration, duration) in migrations {
+                println!(
+                    "Ran {} {} {:?}",
+                    migration.version, migration.description, duration
+                );
+            }
+
+            return Ok(());
+        }
+        Some("serve") => {
+            let config = envy::from_env::<Config>().expect("error loading config");
+            init_tracing(&config);
+
+            let state = AppState::new(config).await?;
+            let listener = TcpListener::bind(&format!("0.0.0.0:{}", state.config.http_port))
+                .expect("failed to bind TCP listener");
+
+            let rpc_server = rpc::Server::new(state.clone(), None);
+            rpc_server
+                .start_recording_project_activity(Duration::from_secs(5 * 60), rpc::RealExecutor);
+
+            let app = api::routes(rpc_server.clone(), state.clone())
+                .merge(rpc::routes(rpc_server.clone()))
+                .merge(Router::new().route("/", get(handle_root)));
+
+            axum::Server::from_tcp(listener)?
+                .serve(app.into_make_service_with_connect_info::<SocketAddr>())
+                .with_graceful_shutdown(graceful_shutdown(rpc_server, state))
+                .await?;
+        }
+        _ => {
+            Err(anyhow!("usage: collab <version | migrate | serve>"))?;
+        }
+    }
     Ok(())
 }
 
+async fn handle_root() -> String {
+    format!("collab v{VERSION}")
+}
+
 pub fn init_tracing(config: &Config) -> Option<()> {
     use std::str::FromStr;
     use tracing_subscriber::layer::SubscriberExt;

script/bootstrap 🔗

@@ -7,7 +7,7 @@ echo "creating database..."
 script/sqlx database create
 
 echo "migrating database..."
-script/sqlx migrate run
+cargo run -p collab -- migrate
 
 echo "seeding database..."
 script/seed-db

script/deploy 🔗

@@ -1,37 +1,22 @@
 #!/bin/bash
 
-# Prerequisites:
-#
-# - Log in to the DigitalOcean docker registry
-#   doctl registry login
-#
-# - Target the `zed-1` kubernetes cluster
-#   doctl kubernetes cluster kubeconfig save zed-1
-
 set -eu
+source script/lib/deploy-helpers.sh
 
-if [[ $# < 1 ]]; then
-  echo "Usage: $0 [production|staging|...]"
-  exit 1
-fi
-
-export ZED_KUBE_NAMESPACE=$1
-ENV_FILE="crates/collab/k8s/environments/${ZED_KUBE_NAMESPACE}.sh"
-if [[ ! -f $ENV_FILE ]]; then
-  echo "Invalid environment name '${ZED_KUBE_NAMESPACE}'"
+if [[ $# < 2 ]]; then
+  echo "Usage: $0 <production|staging|preview> <tag-name>"
   exit 1
 fi
+environment=$1
+version=$2
 
-if [[ $ZED_KUBE_NAMESPACE == "production" && -n $(git status --short) ]]; then
-  echo "Cannot deploy uncommited changes to production"
-  exit 1
-fi
+export_vars_for_environment ${environment}
+image_id=$(image_id_for_version ${version})
 
-git_sha=$(git rev-parse HEAD)
-export ZED_IMAGE_ID="registry.digitalocean.com/zed/collab:${ZED_KUBE_NAMESPACE}-${git_sha}"
-export $(cat $ENV_FILE)
-
-docker build . --tag "$ZED_IMAGE_ID"
-docker push "$ZED_IMAGE_ID"
+export ZED_KUBE_NAMESPACE=${environment}
+export ZED_IMAGE_ID=${image_id}
 
+target_zed_kube_cluster
 envsubst < crates/collab/k8s/manifest.template.yml | kubectl apply -f -
+
+echo "deployed collab v${version} to ${environment}"

script/deploy-migration 🔗

@@ -1,42 +1,26 @@
 #!/bin/bash
 
-# Prerequisites:
-#
-# - Log in to the DigitalOcean docker registry
-#   doctl registry login
-#
-# - Target the `zed-1` kubernetes cluster
-#   doctl kubernetes cluster kubeconfig save zed-1
-
 set -eu
+source script/lib/deploy-helpers.sh
 
-if [[ $# < 1 ]]; then
-  echo "Usage: $0 [production|staging|...]"
-  exit 1
-fi
-
-export ZED_KUBE_NAMESPACE=$1
-ENV_FILE="crates/collab/k8s/environments/${ZED_KUBE_NAMESPACE}.sh"
-if [[ ! -f $ENV_FILE ]]; then
-  echo "Invalid environment name '${ZED_KUBE_NAMESPACE}'"
+if [[ $# < 2 ]]; then
+  echo "Usage: $0 <production|staging|preview> <tag-name>"
   exit 1
 fi
+environment=$1
+version=$2
 
-if [[ -n $(git status --short) ]]; then
-  echo "Cannot deploy with uncommited changes"
-  exit 1
-fi
+export_vars_for_environment ${environment}
+image_id=$(image_id_for_version ${version})
 
-git_sha=$(git rev-parse HEAD)
-export ZED_IMAGE_ID=registry.digitalocean.com/zed/zed-migrator:${ZED_KUBE_NAMESPACE}-${git_sha}
-export ZED_MIGRATE_JOB_NAME=zed-migrate-${git_sha}
-
-docker build . \
-  --file ./Dockerfile.migrator \
-  --tag $ZED_IMAGE_ID
-docker push $ZED_IMAGE_ID
+export ZED_KUBE_NAMESPACE=${environment}
+export ZED_IMAGE_ID=${image_id}
+export ZED_MIGRATE_JOB_NAME=zed-migrate-${version}
 
+target_zed_kube_cluster
 envsubst < crates/collab/k8s/migrate.template.yml | kubectl apply -f -
 
-pod=$(kubectl --namespace=${ZED_KUBE_NAMESPACE} get pods --selector=job-name=${ZED_MIGRATE_JOB_NAME} --output=jsonpath='{.items[*].metadata.name}')
-echo "pod:" $pod
+pod=$(kubectl --namespace=${environment} get pods --selector=job-name=${ZED_MIGRATE_JOB_NAME} --output=jsonpath='{.items[0].metadata.name}')
+
+echo "Job pod:" $pod
+kubectl --namespace=${environment} logs -f ${pod}

script/lib/bump-version.sh 🔗

@@ -0,0 +1,39 @@
+#!/bin/bash
+
+set -eu
+
+if [[ $# < 3 ]]; then
+  echo "Missing version increment (major, minor, or patch)" >&2
+  exit 1
+fi
+
+package=$1
+tag_prefix=$2
+version_increment=$3
+
+if [[ -n $(git status --short --untracked-files=no) ]]; then
+  echo "Can't push a new version with uncommitted changes"
+  exit 1
+fi
+
+which cargo-set-version > /dev/null || cargo install cargo-edit
+cargo set-version --package $package --bump $version_increment
+cargo check --quiet
+
+new_version=$(cargo metadata --no-deps --format-version=1 | jq --raw-output ".packages[] | select(.name == \"${package}\") | .version")
+branch_name=$(git rev-parse --abbrev-ref HEAD)
+old_sha=$(git rev-parse HEAD)
+tag_name=${tag_prefix}${new_version}
+
+git commit --quiet --all --message "${package} ${new_version}"
+git tag ${tag_name}
+
+cat <<MESSAGE
+Committed and tagged ${package} version ${new_version}
+
+To push this:
+    git push origin ${tag_name} ${branch_name}
+
+To undo this:
+    git tag -d ${tag_name} && git reset --hard $old_sha
+MESSAGE

script/lib/deploy-helpers.sh 🔗

@@ -0,0 +1,40 @@
+function export_vars_for_environment {
+  local environment=$1
+  local env_file="crates/collab/k8s/environments/${environment}.sh"
+  if [[ ! -f $env_file ]]; then
+    echo "Invalid environment name '${environment}'" >&2
+    exit 1
+  fi
+  export $(cat $env_file)
+}
+
+function image_id_for_version {
+  local version=$1
+
+  # Check that version is valid
+  if [[ ! ${version} =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+    echo "Invalid version number '${version}'" >&2
+    exit 1
+  fi
+
+  # Check that image exists for version
+  tag_names=$(doctl registry repository list-tags collab --no-header --format Tag)
+  if ! $(echo "${tag_names}" | grep -Fqx v${version}); then
+    echo "No docker image tagged for version '${version}'" >&2
+    echo "Found images with these tags:" ${tag_names} >&2
+    exit 1
+  fi
+  
+  echo "registry.digitalocean.com/zed/collab:v${version}"
+}
+
+function version_for_image_id {
+  local image_id=$1
+  echo $image_id | cut -d: -f2
+}
+
+function target_zed_kube_cluster {
+  if [[ $(kubectl config current-context 2> /dev/null) != do-nyc1-zed-1 ]]; then
+    doctl kubernetes cluster kubeconfig save zed-1
+  fi
+}

script/what-is-deployed 🔗

@@ -0,0 +1,35 @@
+#!/bin/bash
+
+set -eu
+source script/lib/deploy-helpers.sh
+
+if [[ $# < 1 ]]; then
+  echo "Usage: $0 <production|staging|preview>"
+  exit 1
+fi
+environment=$1
+
+export_vars_for_environment ${environment}
+target_zed_kube_cluster
+
+deployed_image_id=$(
+  kubectl \
+    --namespace=${environment} \
+    get deployment collab \
+    -o 'jsonpath={.spec.template.spec.containers[0].image}' \
+    | cut -d: -f2
+)
+
+job_image_ids=$(
+  kubectl \
+    --namespace=${environment} \
+    get jobs \
+    -o 'jsonpath={range .items[0:5]}{.spec.template.spec.containers[0].image}{"\n"}{end}'
+)
+
+echo "Deployed image version:"
+echo "$deployed_image_id"
+echo
+echo "Migration job image versions:"
+echo "$job_image_ids"
+echo