deployToCloudRun method

Future<String?> deployToCloudRun({
  1. String repository = 'oracular',
  2. String region = 'us-central1',
})

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:

  1. 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.
  2. Docker auth (configure-docker against the regional *-docker.pkg.dev host).
  3. Docker build (docker build --platform linux/amd64 -t imageTag . from the server directory).
  4. Docker push of imageTag.
  5. Cloud Run deploy of imageTag to serviceName.

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;
}