Deep dive

Deep Dive: Our CI/CD

The delivery pipeline written as a generic model - real structure, pseudo-code implementations. Any provider can run it; that is the point.

This is the CI/CD model we deploy engagements on, written as code. Not a product and not a specific tool's syntax - TypeScript pseudo-code where the structure is real and the implementations are stubs. We keep it in this form deliberately: the model is the asset, and Jenkins, GitHub Actions, GitLab, or a folder of plain shell scripts are interchangeable implementations of it. The same model runs today behind both a pipeline server and a no-CI-server script variant.

Read it the way you would read an architecture: the function boundaries and the order of operations are the design.

Four pipelines, separated on purpose

pipeline.ts
interface PipelineContext {
    workspace: Workspace;
    secrets: Secrets;
    artifacts: ArtifactRegistry;
    analyser: ArtifactAnalyser;
    cloud: CloudProvider;
}

function pipeline(ctx: PipelineContext) {

    // build all images, this is just code into artifacts
    ciPipeline(ctx);

    // create cloud infra
    // this does global and env specific setups
    infraPipeline(ctx);

    // post infra work, db create, users, rotations
    infraOpsPipeline(ctx);

    // deploy things to specific env
    cdPipeline(ctx);
}

Build, provision, operate, deploy - four pipelines, four concerns. CI turns code into artifacts and knows nothing about environments. Infra provisions cloud resources and knows nothing about applications. Infra-ops covers the awkward middle nobody assigns an owner to - database creation, users, rotations - the steps that need both what was built and what will run. CD takes versions to an environment. When these are one pipeline, every concern leaks into every other; separated, each can change, fail, and rerun alone.

CI: code into artifacts, idempotently

pipeline.ts - ciPipeline
function ciPipeline(ctx: PipelineContext) {

    /**
     * Step 1 - Java builds
     *          configure entire workspace
     *          common lib, app, and sql bundle
     *          lib scanned for sonar and dependencies
     */

    ctx.workspace.agent('jdk17');

    var opsRepo = cloneRepo('https://ops-repo', 'main');

    const settingsXmlTemplate = opsRepo.file('templates/settings.xml');
    ctx.workspace.configure(mavenConfigurer(settingsXmlTemplate));

    var appRepo = cloneRepo('https://app-repo', 'main');

    if (!ctx.artifacts.exists(appRepo.file('lib-common/pom.xml'))) {
        const bundle = ctx.workspace.build('lib-common/pom.xml', mavenBuilder);
        ctx.artifacts.publish('lib-common/pom.xml', bundle);
    }
    if (!ctx.analyser.exists(appRepo.file('lib-common/pom.xml'), sonarScanner)) {
        ctx.analyser.publish('lib-common/pom.xml', sonarScanner);
    }
    if (!ctx.analyser.exists(appRepo.file('lib-common/pom.xml'), dependencyTrackScanner)) {
        ctx.analyser.publish('lib-common/pom.xml', dependencyTrackScanner);
    }

    if (!ctx.artifacts.exists(appRepo.file('app-xxx/pom.xml'))) {
        const jarBundle = ctx.workspace.build('app-xxx/pom.xml', mavenBuilder);
        ctx.artifacts.publish('app-xxx/pom.xml', jarBundle);
    }
    if (!ctx.artifacts.exists(appRepo.file('app-xxx/sql.yaml'))) {
        const sqlBundle = ctx.workspace.build('app-xxx/sql.yaml', sqlBuilder);
        ctx.artifacts.publish('sql/app-xxx/sql.yaml', sqlBundle);
    }

    /**
     * Step 2 - node builds
     *          get template file and render per project
     *          node is per project vs workspace
     */

    ctx.workspace.agent('node20');

    var opsRepo = cloneRepo('https://ops-repo', 'main');
    const npmrcTemplate = opsRepo.file('templates/.npmrc');

    var appRepo = cloneRepo('https://app-repo', 'main');

    if (!ctx.artifacts.exists(appRepo.file('app-ui/package.json'))) {

        ctx.workspace.configure(npmConfigurer(appRepo.file(npmrcTemplate)));

        const bundle = ctx.workspace.build('app-ui/package.json', npmBuilder);
        ctx.artifacts.publish('app-ui/package.json', bundle);
    }

    /**
     * Step 3 - now do docker builds
     *          Note: our docker build source is in the ops repo
     *          configure workspace and then build each
     *          docker needs a login first so any builds are done.
     *          Dockerfile code downloads from artifact store (build above)
     *          docker builder needs to pull dependencies (in dockerfile from)
     */

    ctx.workspace.agent('docker');

    var opsRepo = cloneRepo('https://ops-repo', 'main');

    const dockerConfigTemplate = opsRepo.file('templates/docker-config.json');
    ctx.workspace.configure(dockerConfigurer(dockerConfigTemplate));
    ctx.workspace.configure(dockerSecretsConfigurer());
    dockerRepoConnector();

    // build base images first
    if (!ctx.artifacts.exists(appRepo.file('base/ubuntu/docker-compose.yaml'))) {
        const bundle = ctx.workspace.build('base/ubuntu/docker-compose.yaml', dockerBuilder);
        ctx.artifacts.publish('base/ubuntu/docker-compose.yaml', bundle);
    }
    if (!ctx.artifacts.exists(appRepo.file('base/jdk17/docker-compose.yaml'))) {
        const bundle = ctx.workspace.build('base/jdk17/docker-compose.yaml', dockerBuilder);
        ctx.artifacts.publish('base/jdk17/docker-compose.yaml', bundle);
    }
    if (!ctx.artifacts.exists(appRepo.file('base/node20/docker-compose.yaml'))) {
        const bundle = ctx.workspace.build('base/node20/docker-compose.yaml', dockerBuilder);
        ctx.artifacts.publish('base/node20/docker-compose.yaml', bundle);
    }

    // now app builds
    if (!ctx.artifacts.exists(appRepo.file('docker-app-xxx/docker-compose.yaml'))) {
        const bundle = ctx.workspace.build('docker-app-xxx/docker-compose.yaml', dockerBuilder);
        ctx.artifacts.publish('docker-app-xxx/docker-compose.yaml', bundle);
    }
    if (!ctx.artifacts.exists(appRepo.file('docker-app-ui/docker-compose.yaml'))) {
        const bundle = ctx.workspace.build('docker-app-ui/docker-compose.yaml', dockerBuilder);
        ctx.artifacts.publish('docker-app-ui/docker-compose.yaml', bundle);
    }
}

Two things carry this function. First, every build is gated on artifacts.exists(...) - create-if-not-exists is the default pattern, so the pipeline can rerun from the top at any time and only do missing work. Second, the order: base images are built before app images, from controlled sources, so an application build never reaches the public internet for a base it cannot reproduce. Scanning publishes through the same exists-gate - SBOMs and static analysis are artifacts too, not side effects.

Infra and infra-ops: provision, then operate

pipeline.ts - infraPipeline + infraOpsPipeline
function infraPipeline(ctx: PipelineContext) {

    ctx.workspace.agent('infra');

    var opsRepo = cloneRepo('https://ops-repo', 'main');

    // aws config
    const awsCredentialsTemplate = opsRepo.file('templates/aws/credentials');
    const awsConfigTemplate = opsRepo.file('templates/aws/.config');
    ctx.workspace.configure(awsConfigurer(awsCredentialsTemplate, awsConfigTemplate));

    // helm repo loader
    ctx.workspace.configure(helmRepoLoader());

    // create remote state for TF
    ctx.cloud.cli(createRemoteState());

    // TF plan and apply
    ctx.cloud.cli(terraformPlanner('global'));
    ctx.cloud.cli(terraformApply('global'));
}

function infraOpsPipeline(ctx: PipelineContext) {

    ctx.workspace.agent('infra');

    var opsRepo = cloneRepo('https://ops-repo', 'main');

    const awsCredentialsTemplate = opsRepo.file('templates/aws/credentials');
    const awsConfigTemplate = opsRepo.file('templates/aws/.config');
    ctx.workspace.configure(awsConfigurer(awsCredentialsTemplate, awsConfigTemplate));

    const rdsHost = ctx.cloud.getResource('infra-dev-1-rds-pg-instance');
    const cognitoUserPoolId = ctx.cloud.getResource('infra-dev-1-user-pool');

    ctx.workspace.agent('sql');

    var opsRepo = cloneRepo('https://ops-repo', 'main');

    // sets up pg env vars for connectivity
    ctx.workspace.configure(pgSqlHostConfigurer(rdsHost));

    // run through our scripts
    var sqlCommands = opsRepo.file('sql/new-db-setup-template.sql');
    sqlCommands = sqlCommands.replace('$DB_NAME', 'db-sample-1');
    sqlCommands = sqlCommands.replace('$DB_USER', 'db-sample-user');

    // run by connecting to infra host configured
    sqlClientRunner(sqlCommands);

    ctx.cloud.cli(cognitoInitialUserCreate(cognitoUserPoolId));
}

Terraform owns provisioning with remote state; that part is short because it should be. The second function is the one most setups are missing as a named thing: post-provision operations. Creating the database and its roles, seeding the first identity-pool user - work that is not infrastructure and not application, and that goes untracked when it has no pipeline of its own. Note also what is absent: no hardcoded endpoints anywhere. cloud.getResource('...') resolves everything at runtime by name.

CD: desired state, promoted

pipeline.ts - cdPipeline
function cdPipeline(ctx: PipelineContext) {

    /**
     * Step 0 - get deploy versions
     *          remember CI is based on SemVer in code
     *          while deploy is based on SemVer that is configured or "desired"
     */

    var opsRepo = cloneRepo('https://ops-repo', 'main');
    var deployConfig = opsRepo.file('environments/dev/deploy.yaml');

    const appXXXVersion = deployConfig['app-xxx']; // "1.0.0"

    /**
     * Step 1 - get resources for env
     */

    ctx.workspace.agent('infra');

    const rdsHost = ctx.cloud.getResource('infra-dev-1-rds-pg-instance');
    const ecrRepo = ctx.cloud.getResource('infra-dev-1-apps-1-ecr');
    const clusterName = 'infra-dev-1-eks';

    /**
     * Step 2 - apply sql as deploy job based on version
     */

    ctx.workspace.agent('sql');

    ctx.workspace.configure(pgSqlHostConfigurer(rdsHost));

    // get our sql bundle and run the updates.sql from within bundle
    const sqlBundle = ctx.artifacts.get('group:name:' + appXXXVersion);
    var updatesFile = sqlBundle.extract().get('updates.sql');
    sqlClientRunner(updatesFile);

    /**
     * Step 3 - promote containers
     *          we need to ensure we are also connected to
     *          cloud docker repo
     */

    ctx.workspace.agent('infra');

    dockerRepoConnector();
    dockerRepoCloudConnector(ecrRepo);

    // get from artifact store and publish to cloud
    const dockerImage = ctx.artifacts.get('acme/app-xxx/' + appXXXVersion);
    ctx.cloud.promoteImage(dockerImage, ecrRepo);

    /**
     * Step 4 - deploy artifacts
     */

    clusterConnector(clusterName);

    var appsTemplate = opsRepo.file('apps/k8/app-xxx.yaml');
    var appsDeploy = appsTemplate.replace('$XXX', 'xxxx');

    ctx.cloud.cli(kubeCtlApply(appsDeploy));
}

Step 0 is the model's center of gravity: CI versions come from the code (SemVer in the build files), CD versions come from a committed deploy config - the desired state of the environment. CI is dynamic, CD is configured. From there it is a promotion, never a rebuild: schema updates applied from the versioned SQL bundle, the already-built image promoted from the artifact store to the cloud registry, templated manifests applied to the cluster. Deploying is moving a number in a YAML file; everything else follows from it.

The interfaces are the portability claim

pipeline.ts - the interfaces
interface Workspace {
    agent: (runtime: string) => void;
    configure: (configurer: any) => void;
    build: (artifact: string, builder: any) => any;
}

interface ArtifactRegistry {
    /**
     * Uses the core artifact file and extracts out group, artifact and version.
     * @param artifact artifact file such as pom, toml, package.json, docker-compose
     * @returns does this group/artifact exist in registry
     */
    exists: (artifact: string) => Boolean;
    publish: (artifact: string, bundle: any) => void;
    get: (artifact: string) => any;
}

interface ArtifactAnalyser {
    exists: (artifact: string, analyser: any) => Boolean;
    publish: (artifact: string, analyser: any) => void;
}

interface CloudProvider {
    getResource: (name: string) => string;
    cli: (runner: any) => void;
    promoteImage: (image: string, repo: string) => void;
}

Four small interfaces carry the entire model: somewhere to run builds, somewhere to keep artifacts, somewhere to record analysis, and a cloud you can ask questions. Any CI provider you already pay for implements these. So does a folder of shell scripts and the AWS CLI - which is exactly how the no-server variant works. Adopting the model is not a migration; it is mapping these four interfaces onto whatever you run today.

Also read

AI Code NinesContext and Session Management
See A Typical EngagementLet's Talk

Don't take our word for it - ask ChatGPT what it thinks of this piece.