Skip to content

Commit

Permalink
chore(runtime): Load project config from environment
Browse files Browse the repository at this point in the history
Loads the resolved project AST from the platform environment/filesystem at startup. This will be used to configure variables, secrets, auth, and routing without managing many environment variables.
  • Loading branch information
dnys1 committed Oct 10, 2024
1 parent 2f318c2 commit 5019dec
Show file tree
Hide file tree
Showing 6 changed files with 244 additions and 71 deletions.
16 changes: 16 additions & 0 deletions packages/celest/lib/src/core/context.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import 'dart:io' show HandshakeException, HttpClient, SocketException;
import 'package:celest/src/config/config_values.dart';
import 'package:celest/src/core/environment.dart';
import 'package:celest/src/runtime/gcp/gcp.dart';
import 'package:celest_ast/celest_ast.dart';
import 'package:celest_core/_internal.dart';
// ignore: implementation_imports
import 'package:celest_core/src/auth/user.dart';
import 'package:cloud_http/cloud_http.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart' as http;
import 'package:http/retry.dart' as http;
Expand Down Expand Up @@ -101,6 +104,10 @@ final class Context {
/// The platform of the current context.
Platform get platform => get(ContextKey.platform) ?? const LocalPlatform();

/// The file system of the current context.
FileSystem get fileSystem =>
get(ContextKey.fileSystem) ?? const LocalFileSystem();

/// Whether Celest is running in the cloud.
bool get isRunningInCloud => root.get(googleCloudProjectKey) != null;

Expand All @@ -113,6 +120,9 @@ final class Context {
/// The Celest [Environment] of the running service.
Environment get environment => expect(env.environment) as Environment;

/// The resolved project configuration for the current context.
ResolvedProject get project => expect(ContextKey.project);

/// The HTTP client for the current context.
http.Client get httpClient =>
get(ContextKey.httpClient) ?? _defaultHttpClient;
Expand Down Expand Up @@ -235,9 +245,15 @@ abstract interface class ContextKey<V extends Object> {
/// The context key for the context [Logger].
static const ContextKey<Logger> logger = ContextKey('logger');

/// The context key for the context [FileSystem].
static const ContextKey<FileSystem> fileSystem = ContextKey('file system');

/// The context key for the context [Platform].
static const ContextKey<Platform> platform = ContextKey('platform');

/// The context key for the context [ResolvedProject].
static const ContextKey<ResolvedProject> project = ContextKey('project');

/// Reads the value for `this` from the given [context].
V? read(Context context);

Expand Down
75 changes: 75 additions & 0 deletions packages/celest/lib/src/runtime/configuration.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import 'package:celest/src/config/config_values.dart';
import 'package:celest/src/core/context.dart';
import 'package:celest/src/runtime/http/logging.dart';
import 'package:celest_ast/celest_ast.dart';
// ignore: implementation_imports
import 'package:celest_ast/src/proto/celest/ast/v1/resolved_ast.pb.dart' as pb;
import 'package:celest_core/_internal.dart';
import 'package:logging/logging.dart';

Map<String, Object?>? _loadJsonFromEnv(Context rootContext) {
final configJson = rootContext.platform.environment['CELEST_CONFIG_JSON'];
if (configJson == null) {
return null;
}
try {
return JsonUtf8.decodeMap(configJson);
} on FormatException catch (e) {
throw StateError('Failed to parse CELEST_CONFIG_JSON: $e');
}
}

Future<Map<String, Object?>?> _loadJsonFromFileSystem(
Context rootContext,
) async {
var configPath = rootContext.platform.environment['CELEST_CONFIG'];
if (configPath == null) {
final script = rootContext.platform.script;
configPath = script.resolve('./celest.json').toFilePath();
}
if (!rootContext.fileSystem.isFileSync(configPath)) {
return null;
}

final configFile = rootContext.fileSystem.file(configPath);
final configData = await configFile
.readAsBytes()
.onError((e, _) => throw StateError('Failed to load celest.json: $e'));

try {
return JsonUtf8.decodeMap(configData);
} on FormatException catch (e) {
throw StateError('Failed to parse celest.json from "$configPath": $e');
}
}

/// Configures the environment in which Celest is running.
Future<void> configure() async {
configureLogging();

final rootContext = Context.root;

final configJson = _loadJsonFromEnv(rootContext) ??
await _loadJsonFromFileSystem(rootContext);
if (configJson == null) {
throw StateError(
'No project configuration found. Create a celest.json file and set '
'CELEST_CONFIG with its path or CELEST_CONFIG_JSON with its contents.',
);
}

final configPb = pb.ResolvedProject()..mergeFromProto3Json(configJson);
final config = ResolvedProject.fromProto(configPb);
Logger.root
..config('Loaded project configuration')
..config(config);

rootContext.put(ContextKey.project, config);

for (final ResolvedVariable(:name, :value) in config.variables) {
rootContext.put(env(name), value);
}
for (final ResolvedSecret(:name, :value) in config.secrets) {
rootContext.put(secret(name), value);
}
}
67 changes: 0 additions & 67 deletions packages/celest/lib/src/runtime/http/environment.dart

This file was deleted.

6 changes: 2 additions & 4 deletions packages/celest/lib/src/runtime/serve.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@ import 'dart:io';

import 'package:async/async.dart';
import 'package:celest/celest.dart';
import 'package:celest/src/runtime/configuration.dart';
import 'package:celest/src/runtime/gcp/gcp.dart';
import 'package:celest/src/runtime/http/cloud_middleware.dart';
import 'package:celest/src/runtime/http/environment.dart';
import 'package:celest/src/runtime/http/logging.dart';
import 'package:celest/src/runtime/http/middleware.dart';
import 'package:celest/src/runtime/json_utils.dart';
import 'package:celest/src/runtime/sse/sse_handler.dart';
Expand All @@ -35,8 +34,7 @@ const int defaultCelestPort = 7777;
Future<void> serve({
required Map<String, CloudFunctionTarget> targets,
}) async {
configureLogging();
await configureEnvironment();
await configure();
final projectId = await googleCloudProject();
if (projectId != null) {
Context.root.put(googleCloudProjectKey, projectId);
Expand Down
3 changes: 3 additions & 0 deletions packages/celest/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ environment:

dependencies:
async: ^2.11.0
celest_ast: ^0.1.0
celest_auth: 1.0.0-dev.0
celest_cloud: ^0.1.1
celest_core: 1.0.0-dev.0
Expand All @@ -18,6 +19,7 @@ dependencies:
collection: ^1.18.0
convert: ^3.1.1
crypto_keys: ^0.3.0
file: ^7.0.1
fixnum: ^1.1.0
google_cloud: ^0.2.0
http: ^1.0.0
Expand All @@ -37,6 +39,7 @@ dependencies:
dev_dependencies:
celest_lints:
path: ../celest_lints
pub_semver: ^2.1.4
stream_transform: ^2.1.0
test: ^1.25.1
web: '>=0.5.0 <2.0.0'
148 changes: 148 additions & 0 deletions packages/celest/test/runtime/configuration_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
@TestOn('vm')
library;

import 'dart:convert';

import 'package:celest/src/config/config_values.dart';
import 'package:celest/src/core/context.dart';
import 'package:celest/src/runtime/configuration.dart';
import 'package:celest_ast/celest_ast.dart';
import 'package:file/memory.dart';
import 'package:platform/platform.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:test/test.dart';

final testProject = ResolvedProject(
projectId: 'test',
environmentId: 'local',
sdkConfig: SdkConfiguration(
celest: Version.parse('1.0.0'),
dart: Sdk(
type: SdkType.dart,
version: Version.parse('3.5.0'),
enabledExperiments: ['native-assets'],
),
targetSdk: SdkType.dart,
featureFlags: [],
),
apis: {
'greeting': ResolvedApi(
apiId: 'greeting',
functions: {
'hello-world': ResolvedCloudFunction(
apiId: 'greeting',
functionId: 'hello-world',
variables: ['HELLO_WORLD'],
secrets: ['HELLO_WORLD_SECRET'],
streamConfig: null,
httpConfig: ResolvedHttpConfig(
method: 'POST',
route: ResolvedHttpRoute(
path: '/greeting/hello-world',
),
status: 200,
statusMappings: {},
),
),
},
),
},
variables: [
ResolvedVariable(
name: 'HELLO_WORLD',
value: 'hello',
),
],
secrets: [
ResolvedSecret(
name: 'HELLO_WORLD_SECRET',
value: 'world',
),
],
);

void main() {
group('configure', () {
test('loads ./celest.json', () async {
final platform = FakePlatform(
environment: {},
script: Uri.parse('file:///app/test.aot'),
);
final fileSystem = MemoryFileSystem.test();
final configFile = fileSystem.file('/app/celest.json');
configFile.parent.createSync();
configFile.writeAsStringSync(
jsonEncode(testProject.toProto().toProto3Json()),
);

context.put(ContextKey.platform, platform);
context.put(ContextKey.fileSystem, fileSystem);
Context.root = context;

await configure();

expect(context.project, equals(testProject));
expect(context.get(const env('HELLO_WORLD')), equals('hello'));
expect(context.get(const secret('HELLO_WORLD_SECRET')), equals('world'));
});

test('loads CELEST_CONFIG', () async {
final platform = FakePlatform(
environment: {
'CELEST_CONFIG': '/config/celest.json',
},
script: Uri.parse('file:///app/test.aot'),
);
final fileSystem = MemoryFileSystem.test();
final configFile = fileSystem.file('/config/celest.json');
configFile.parent.createSync();
configFile.writeAsStringSync(
jsonEncode(testProject.toProto().toProto3Json()),
);

context.put(ContextKey.platform, platform);
context.put(ContextKey.fileSystem, fileSystem);
Context.root = context;

await configure();

expect(context.project, equals(testProject));
expect(context.get(const env('HELLO_WORLD')), equals('hello'));
expect(context.get(const secret('HELLO_WORLD_SECRET')), equals('world'));
});

test('loads CELEST_CONFIG_JSON', () async {
final platform = FakePlatform(
environment: {
'CELEST_CONFIG_JSON': jsonEncode(
testProject.toProto().toProto3Json(),
),
},
script: Uri.parse('file:///app/test.aot'),
);

context.put(ContextKey.platform, platform);
Context.root = context;

await configure();

expect(context.project, equals(testProject));
expect(context.get(const env('HELLO_WORLD')), equals('hello'));
expect(context.get(const secret('HELLO_WORLD_SECRET')), equals('world'));
});

test('fails hard when missing', () async {
final platform = FakePlatform(
environment: {},
script: Uri.parse('file:///app/test.aot'),
);
final fileSystem = MemoryFileSystem.test();

context.put(ContextKey.platform, platform);
context.put(ContextKey.fileSystem, fileSystem);
Context.root = context;

await expectLater(configure(), throwsStateError);
});
});
}

0 comments on commit 5019dec

Please sign in to comment.