deployToCloudRun method
Build, push, and deploy the server to Google Cloud Run.
This is the Dart-side equivalent of the generated script_deploy.sh
shell script and is what oracular deploy all calls when
SetupConfig.createServer is true. The end-to-end flow is:
- Models snapshot (
cp -r [modelsDir] [serverDir]/[modelsDir]/) — only when SetupConfig.createModels is true. The Docker build context needs the models package alongside the server source. - Docker auth (configure-docker against the regional
*-docker.pkg.devhost). - Docker build (
docker build --platform linux/amd64 -timageTag.from the server directory). - Docker push of
imageTag. - Cloud Run deploy of
imageTagtoserviceName.
Failures short-circuit and return false. Each step uses
_runner.runWithRetry so transient docker/network errors get the
same retry semantics as everything else in Oracular.
Returns the live Cloud Run URL on success; null on failure.
Implementation
Future<String?> deployToCloudRun({
String repository = 'oracular',
String region = 'us-central1',
}) async {
if (!config.createServer) {
warn('Server is not enabled for this project — skipping Cloud Run '
'deploy.');
return null;
}
final String? projectId = config.firebaseProjectId;
if (projectId == null || projectId.trim().isEmpty) {
error('No Firebase / GCP project ID configured — cannot deploy to '
'Cloud Run. Set FIREBASE_PROJECT_ID in config/setup_config.env '
'and re-run.');
return null;
}
final Directory serverDir = Directory(serverPath);
if (!serverDir.existsSync()) {
error('Server package not found at $serverPath. Run '
'`oracular deploy server-setup` first.');
return null;
}
final File dockerfile = File(p.join(serverPath, 'Dockerfile'));
if (!dockerfile.existsSync()) {
warn('No Dockerfile found at ${dockerfile.path} — generating one '
'before deploy.');
await generateDockerfile();
}
// Preflight: detect the most common dev-environment foot-gun BEFORE
// we shell out to gcloud / docker. The user typically has multiple
// credentialed gcloud accounts (personal + per-project SAs), and
// `gcloud config set account …` from a previous oracular run leaves
// a *different* project's service account active. Catching that here
// turns a confusing cascade of "PERMISSION_DENIED enabling
// artifactregistry.googleapis.com" warnings deep in the deploy into
// one clear actionable error up front. Runs AFTER the cheap path
// checks above so that "config wrong" errors don't waste a gcloud
// round-trip.
final CloudRunPreflight preflight = CloudRunPreflight(runner: _runner);
if (!await preflight.verifyActiveGcloudAccount(projectId: projectId)) {
return null;
}
final String imageTag = _imageTag(region: region, repository: repository);
// 1. Copy models snapshot for the Docker build context. Idempotent:
// we delete the previous copy first so old model files can't hang
// around and confuse the build.
if (config.createModels) {
final String modelsPath =
p.join(config.outputDir, config.modelsPackageName);
final String targetPath =
p.join(serverPath, config.modelsPackageName);
final Directory target = Directory(targetPath);
if (target.existsSync()) {
try {
await target.delete(recursive: true);
} catch (_) {
// Best-effort cleanup; cp -r will overwrite.
}
}
info('Copying models snapshot for Docker context...');
final ProcessResult cp = await _runner.run(
'cp',
<String>['-r', modelsPath, targetPath],
);
if (!cp.success) {
error('Failed to copy models package into server build context: '
'${cp.stderr.trim()}');
return null;
}
}
// 2. Configure Docker auth for Artifact Registry.
info('Configuring Docker auth for $region-docker.pkg.dev...');
final ProcessResult? auth = await _runner.runWithRetry(
'gcloud',
<String>[
'auth',
'configure-docker',
'$region-docker.pkg.dev',
'--quiet',
],
operationName: 'gcloud auth configure-docker',
);
if (auth == null || !auth.success) {
error('Failed to configure Docker auth for Artifact Registry. '
'Run `gcloud auth login` and retry.');
return null;
}
// 2b. Ensure the GCP services we need (Artifact Registry + Cloud Run)
// are enabled. On a fresh project these are off by default and the
// docker push will fail with `Artifact Registry API has not been used
// in project … before or it is disabled`. Enabling here is idempotent
// (gcloud reports success when the API is already enabled) and runs
// synchronously enough that propagation typically lands before the
// push step a few seconds later.
if (!await preflight.ensureCloudRunPrerequisiteApis(projectId: projectId)) {
// Non-fatal: we still attempt the push because the API may have
// been enabled in a prior run. The push itself will surface a
// clear error if it really isn't enabled.
warn('Could not confirm Artifact Registry / Cloud Run APIs are '
'enabled. Continuing — push will fail with a clear error if '
'they really are off.');
}
// 2c. Ensure the Artifact Registry repository (`<region>/<repository>`)
// exists. Without this, `docker push` fails with NOT_FOUND because
// the registry path doesn't resolve to an existing repo. Idempotent:
// we treat 409 / "already exists" as success.
if (!await preflight.ensureArtifactRegistryRepository(
projectId: projectId,
repository: repository,
region: region,
)) {
error('Could not ensure Artifact Registry repository '
'`$repository` in $region exists. Push will fail without it.');
return null;
}
// 3. Build Docker image with the AR-qualified tag so we can push
// directly without a separate `docker tag` step.
info('Building Docker image $imageTag...');
final ProcessResult? build = await _runner.runWithRetry(
'docker',
<String>[
'build',
'--platform',
'linux/amd64',
'-t',
imageTag,
'.',
],
workingDirectory: serverPath,
operationName: 'Docker build',
);
if (build == null || !build.success) {
error('Docker build failed for $serverServiceName.');
return null;
}
// 4. Push image to Artifact Registry.
info('Pushing image to Artifact Registry...');
final ProcessResult? push = await _runner.runWithRetry(
'docker',
<String>['push', imageTag],
workingDirectory: serverPath,
operationName: 'Docker push',
);
if (push == null || !push.success) {
error('Docker push failed. Verify the Artifact Registry repository '
'`$repository` exists in $region (run '
'`oracular deploy server-setup` to create it via gcloud).');
return null;
}
// 5. Deploy to Cloud Run.
info('Deploying $serverServiceName to Cloud Run ($region)...');
final ProcessResult? deploy = await _runner.runWithRetry(
'gcloud',
<String>[
'run',
'deploy',
serverServiceName,
'--image=$imageTag',
'--platform=managed',
'--region=$region',
'--project=$projectId',
'--allow-unauthenticated',
'--port=8080',
'--memory=512Mi',
'--cpu=1',
'--min-instances=0',
'--max-instances=10',
],
operationName: 'gcloud run deploy',
);
if (deploy == null || !deploy.success) {
error('gcloud run deploy failed for service $serverServiceName.');
return null;
}
// Cloud Run URLs are not predictable from project-id alone (they
// embed a per-project hash, e.g. `<service>-<hash>-uc.a.run.app`),
// so we ask gcloud for the current URL after the deploy succeeds.
String url = 'https://$serverServiceName-$projectId.$region.run.app';
final ProcessResult describe = await _runner.run(
'gcloud',
<String>[
'run',
'services',
'describe',
serverServiceName,
'--region=$region',
'--project=$projectId',
'--format=value(status.url)',
],
);
final String describedUrl = describe.stdout.trim();
if (describe.success && describedUrl.isNotEmpty) {
url = describedUrl;
}
success('Server deployed to Cloud Run.');
info(' Service URL: $url');
return url;
}