@softvisio/core
Version:
Softisio core
575 lines (451 loc) • 20.6 kB
JavaScript
import childProcess from "node:child_process";
import path from "node:path";
import Ajv from "#lib/ajv";
import DockerEngine from "#lib/api/docker/engine";
import Git from "#lib/api/git";
import { readConfig } from "#lib/config";
import { exists } from "#lib/fs";
import Logger from "#lib/logger";
import { TmpDir } from "#lib/tmp";
import { confirm } from "#lib/utils";
// NOTE https://docs.docker.com/compose/compose-file/build/
const schema = new Ajv().addSchema( await readConfig( "#resources/schemas/compose.schema.yaml", { "resolve": import.meta.url } ) );
export default class {
#context;
#signal;
#commitRef;
#autoTags;
#push;
#remove;
#interactive;
#force;
#credentials;
#build = {};
#logger;
#git;
#upstream;
#commit;
#buildVersion;
#tags = new Set();
#composeFile;
#images = [];
#dockerEngine;
constructor ( context, { composeFile, signal, credentials, commitRef, autoTags, push, remove, interactive, force, build = {} } = {} ) {
this.#context = context;
this.#composeFile = composeFile || "compose.yaml";
this.#signal = signal;
this.#commitRef = commitRef;
this.#autoTags = ( Array.isArray( autoTags )
? autoTags
: [ autoTags ] ).filter( tag => typeof tag === "string" );
this.#push = push;
this.#remove = remove;
this.#interactive = interactive;
this.#force = force;
this.#credentials = credentials;
// build options
this.#build.dockerFile = build.dockerFile;
this.#build.tag = build.tag;
this.#build.extraHosts = build.extraHosts;
this.#build.quiet = build.quiet;
this.#build.cache = build.cache;
this.#build.cacheFrom = build.cacheFrom;
this.#build.pull = build.pull;
this.#build.rm = build.rm;
this.#build.forceRm = build.forceRm;
this.#build.args = build.args || {};
this.#build.labels = build.labels || {};
this.#build.shmSize = build.shmSize;
this.#build.memory = build.memory;
this.#build.memSwap = build.memSwap;
this.#build.cpuShares = build.cpuShares;
this.#build.cpuSetCpus = build.cpuSetCpus;
this.#build.cpuPeriod = build.cpuPeriod;
this.#build.cpuQuota = build.cpuQuota;
this.#build.squash = build.squash;
this.#build.networkMode = build.networkMode;
this.#build.platform = build.platform;
this.#build.target = build.target;
this.#build.outputs = build.outputs;
if ( !this.#interactive ) this.#force = true;
this.#logger = new Logger();
if ( !this.#interactive ) this.#dockerEngine = new DockerEngine();
}
// public
async run () {
var res, clone;
try {
// prepare context
res = await this.#prepareContext();
if ( !res.ok ) throw res;
clone = res.data.clone;
this.#logger.log( `\nBuild version: ${ this.#buildVersion }\n` );
// prepare images
res = await this.#prepareImages();
if ( !res.ok ) throw res;
if ( !this.#images.length ) throw "No imahes to build found";
for ( const image of this.#images ) {
image.tags = [];
for ( const tag of [ ...this.#tags ].sort() ) {
const taggedImage = image.image + ":" + tag;
image.tags.push( taggedImage );
this.#logger.log( "Image: " + taggedImage );
}
}
// confirm build
if ( !this.#force && ( await confirm( "\nContinue build process?", [ "[yes]", "no" ] ) ) !== "yes" ) throw "Aborted";
// clone git repo
if ( clone ) {
const context = this.#context;
this.#context = new TmpDir();
this.#logger.write( "Cloning ... " );
res = await this.#git.exec( [ "clone", "--quiet", context, this.#context.path ] );
if ( !res.ok ) throw result( res );
this.#logger.log( res + "" );
this.#git = new Git( this.#context.path );
this.#logger.write( `Checking out "${ clone }" ... ` );
res = await this.#git.exec( [ "checkout", clone ] );
if ( !res.ok ) throw result( res );
this.#logger.log( res + "\n" );
}
res = await this.#buildImages();
if ( !res.ok ) throw res;
}
catch ( e ) {
res = result.catch( e, { "log": false } );
if ( !res.ok ) this.#logger.error( res + "" );
}
// remove tmp context
if ( this.#context instanceof TmpDir ) this.#context.destroy();
// remove build artifacts
await this.#removeImages();
return res;
}
// private
async #prepareContext () {
const logger = this.#logger;
// read compose.yaml
if ( !( await exists( this.#context + "/" + this.#composeFile ) ) ) return result( [ 404, `Compose file "${ this.#composeFile } not found"` ] );
this.#composeFile = await readConfig( this.#context + "/" + this.#composeFile );
// validate compose.yaml
logger.write( "Validating compose.yaml ... " );
if ( !schema.validate( "compose", this.#composeFile ) ) return result( [ 400, `not valid, errors:\n${ schema.errors }` ] );
logger.log( "OK" );
this.#git = new Git( this.#context );
this.#upstream = this.#git.upstream;
var res;
// get build version
res = await this.#git.getBuildVersion( { "commitRef": this.#commitRef === "."
? "HEAD"
: this.#commitRef } );
if ( !res.ok ) return res;
const buildVersion = res.data;
this.#commit = buildVersion.commit;
this.#buildVersion = buildVersion.version;
// TODO if !this.#commitRef - take commit-ref from image tag
// build dirtyy working tree
if ( this.#commitRef === "." && buildVersion.isDirty ) {
this.#tags.add( "dirty" );
return result( 200, {
"clone": null,
} );
}
// build by commit ref
else {
// add commit ref as tag
if ( this.#commitRef && this.#commitRef !== "." ) {
if ( buildVersion.commit.branches.has( this.#commitRef ) || buildVersion.commit.tags.has( this.#commitRef ) ) {
this.#tags.add( this.#commitRef );
}
else if ( buildVersion.commit.hash.startsWith( this.#commitRef ) ) {
this.#tags.add( buildVersion.commit.abbrev );
}
}
// add autobuild tags
for ( const tag of this.#autoTags ) {
if ( buildVersion.commit.branches.has( tag ) || buildVersion.commit.tags.has( tag ) ) {
this.#tags.add( tag );
}
}
// add default tag
if ( !this.#tags.size ) {
this.#tags.add( buildVersion.commit.abbrev );
}
let clone = null;
if ( buildVersion.isDirty ) {
clone = buildVersion.commit.abbrev;
}
else {
// identify head commit
res = await this.#git.getCommit();
if ( !res.ok ) return res;
const head = res.data;
if ( head.hash !== buildVersion.commit.hash ) {
clone = buildVersion.commit.abbrev;
}
}
return result( 200, {
clone,
} );
}
}
async #prepareImages () {
const images = {};
for ( const service of Object.values( this.#composeFile.services || {} ) ) {
// do not build service
if ( !service.build || !service.image ) continue;
if ( typeof service.build === "string" ) {
service.build = {
"context": service.build,
};
}
// XXX check build.context, do not build if context is not a relative path (can be git url)
const imageName = ( service.baseImage = service.image.replace( /:.*$/, "" ) );
if ( images[ imageName ] ) {
if ( service.depends_on ) {
images[ imageName ].dependencies.push( ...( Array.isArray( service.depends_on )
? service.depends_on
: Object.keys( service.depends_on ) ) );
}
continue;
}
const image = {
"image": imageName,
"dependencies": service.depends_on
? ( Array.isArray( service.depends_on )
? [ ...service.depends_on ]
: Object.keys( service.depends_on ) )
: [],
"build": { ...this.#build },
};
images[ imageName ] = image;
image.context = service.build.context
? path.posix.join( this.#context + "", service.build.context )
: this.#context;
// preparee build options
if ( service.build.dockerfile ) image.build.dockerFile = service.build.dockerfile;
if ( service.build.shm_size ) image.build.shmSize = service.build.shm_size;
if ( service.build.network ) image.build.networkMode = service.build.network;
if ( service.build.target ) image.build.target = service.build.target;
if ( service.build.cache_from ) image.build.cacheFrom = service.build.cache_from;
image.build.args = { ...image.build.args };
image.build.labels = { ...image.build.labels };
// merge build args
if ( service.build.args ) {
if ( Array.isArray( service.build.args ) ) {
for ( const arg of service.build.args ) {
const [ key, value ] = arg.split( /=(.*)/ );
image.build.args[ key ] = value;
}
}
else {
for ( const key in service.build.args ) {
image.build.args[ key ] = service.build.args[ key ];
}
}
}
// add default build args
image.build.args[ "BUILD_VERSION" ] = this.#buildVersion;
// merge labels
if ( service.build.labels ) {
if ( Array.isArray( service.build.labels ) ) {
for ( const label of service.build.labels ) {
const [ key, value ] = label.split( /=(.*)/ );
image.build.labels[ key ] = value;
}
}
else if ( typeof service.build.labels === "object" ) {
for ( const key in service.build.labels ) {
image.build.labels[ key ] = service.build.labels[ key ];
}
}
}
// add default labels
// DOCS: https://specs.opencontainers.org/image-spec/annotations/
image.build.labels[ "org.opencontainers.image.created" ] = new Date().toISOString();
image.build.labels[ "org.opencontainers.image.version" ] = this.#buildVersion;
image.build.labels[ "org.opencontainers.image.revision" ] = this.#commit.hash;
if ( this.#upstream ) {
image.build.labels[ "org.opencontainers.image.url" ] = this.#upstream.readmeUrl;
image.build.labels[ "org.opencontainers.image.documentation" ] = this.#upstream.readmeUrl;
image.build.labels[ "org.opencontainers.image.source" ] = this.#upstream.homeUrl;
image.build.labels[ "org.opencontainers.image.description" ] = this.#upstream.readmeUrl;
}
// image.build.labels[ "org.opencontainers.image.authors" ]\f
// image.build.labels[ "org.opencontainers.image.vendor" ]
// image.build.labels[ "org.opencontainers.image.licenses" ]
// image.build.labels[ "org.opencontainers.image.ref.name" ]
// image.build.labels[ "org.opencontainers.image.title" ]
}
// resolve dependencies
for ( const image of Object.values( images ) ) {
image.dependencies = image.dependencies
.map( serviceName => {
const dependencyImage = this.#composeFile.services?.[ serviceName ]?.baseImage;
if ( !dependencyImage || dependencyImage === image.image ) return;
return dependencyImage;
} )
.filter( image => image );
}
const colors = {},
sort = image => {
const color = colors[ image.image ] || "white";
if ( color === "black" ) {
return result( 200 );
}
else if ( color === "grey" ) {
return result( [ 500, "Cyclic dependency found" ] );
}
else {
colors[ image.image ] = "grey";
for ( const dependency of image.dependencies ) {
const res = sort( images[ dependency ] );
if ( !res.ok ) return res;
}
colors[ image.image ] = "black";
this.#images.push( image );
return result( 200 );
}
};
for ( const image of Object.values( images ) ) {
const res = sort( image );
if ( !res.ok ) return res;
}
return result( 200 );
}
async #buildImages () {
for ( const image of this.#images ) {
this.#logger.log( "" );
this.#logger.info( `Building image: ${ image.image } ...` );
if ( this.#interactive ) {
const args = [];
if ( image.build.dockerFile ) args.push( "--file", image.build.dockerFile );
if ( image.build.shmSize ) args.push( "--shm-size", image.build.shmSize );
if ( image.build.networkMode ) args.push( "--network", image.build.networkMode );
if ( image.build.target ) args.push( "--target", image.build.target );
if ( image.build.cacheFrom ) image.build.cacheFrom.forEach( image => args.push( "--cache-from", image ) );
for ( const arg in image.build.args ) {
args.push( "--build-arg=" + arg + "=" + image.build.args[ arg ] );
}
for ( const label in image.build.labels ) {
args.push( "--label=" + label + "=" + image.build.labels[ label ] );
}
const res = childProcess.spawnSync(
"docker",
[
//
"builder",
"build",
"--rm", // remove intermediate containers after a successful build (default true)
"--force-rm", // always remove intermediate containers
"--no-cache", // do not use cache when building the image
"--pull", // always attempt to pull a newer version of the image
...args,
...image.tags.map( image => "--tag=" + image ),
image.context,
],
{ "stdio": "inherit" }
);
if ( res.status ) return result( 500 );
}
else {
let res = await this.#dockerEngine.buildImage( image.context, {
"dockerfile": "dockerfile",
"signal": this.#signal,
"credentials": this.#credentials,
"options": {
...image.build.options,
"tag": image.tags[ 0 ],
},
} );
if ( !res.ok ) return res;
const stream = res.data;
res = await new Promise( resolve => {
stream.on( "data", data => {
if ( data.stream ) {
this.#logger.write( data.stream );
}
else if ( data.error ) {
this.#logger.error( data.error );
resolve( result( [ 500, data.error ] ) );
}
else if ( data.aux ) {
this.#logger.log( "ID: " + data.aux.ID );
}
} );
stream.once( "error", e => resolve( result.catch( e ) ) );
stream.once( "close", () => resolve( result( 200 ) ) );
} );
if ( !res.ok ) return res;
// tag image
if ( image.tags.length > 1 ) {
for ( let n = 1; n < image.tags.length; n++ ) {
const res = await this.#dockerEngine.tagImage( image.tags[ 0 ], image.tags[ n ] );
if ( !res.ok ) return res;
}
}
}
// push images
if ( this.#push ) {
for ( const tag of image.tags ) {
this.#logger.log( "" );
this.#logger.info( `Pushing: ${ tag }` );
if ( this.#interactive ) {
while ( true ) {
const res = childProcess.spawnSync( "docker", [ "image", "push", tag ], { "stdio": "inherit" } );
if ( !res.status ) break;
if ( ( await confirm( "\nUnable to push image. Repeat?", [ "[yes]", "no" ] ) ) !== "yes" ) break;
}
}
else {
let res = await this.#dockerEngine.pushImage( tag, {
"signal": this.#signal,
"credentials": this.#credentials,
} );
if ( !res.ok ) return res;
const stream = res.data;
res = await new Promise( resolve => {
stream.on( "data", data => {
if ( data.status ) {
// this.#logger.log( data.status );
}
else if ( data.error ) {
this.#logger.error( data.error );
resolve( result( [ 500, data.error ] ) );
}
else if ( data.aux ) {
this.#logger.log( "Digest: " + data.aux.digest );
this.#logger.log( "Size: " + data.aux.size );
}
} );
stream.once( "error", e => resolve( result.catch( e ) ) );
stream.once( "close", () => resolve( result( 200 ) ) );
} );
if ( !res.ok ) return res;
}
}
}
}
return result( 200 );
}
async #removeImages () {
if ( !this.#remove ) return result( 200 );
const images = [];
for ( const image of this.#images ) {
for ( const tag of image.tags ) {
images.push( tag );
}
}
this.#logger.log( "\nRemoving images" );
if ( this.#interactive ) {
childProcess.spawnSync( "docker", [ "image", "rm", ...images ], { "stdio": "inherit" } );
}
else {
for ( const image of images ) {
const res = await this.#dockerEngine.deleteImage( image );
this.#logger.log( "Remove image: " + image + " ... " + res + "" );
}
}
return result( 200 );
}
}