Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions src/Aspire.Hosting.Oracle/OracleDatabaseBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,15 @@ public static IResourceBuilder<OracleDatabaseServerResource> AddOracle(
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);

var passwordParameter = password?.Resource ?? ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder, $"{name}-password");
// Oracle's database creation scripts reject the broader special-character set used by the default
// password generator. The complexity policy still requires lower, upper, and numeric characters.
var passwordParameter = password?.Resource ?? ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(
builder,
$"{name}-password",
special: false,
minLower: 1,
minUpper: 1,
minNumeric: 1);

var oracleDatabaseServer = new OracleDatabaseServerResource(name, passwordParameter);

Expand Down Expand Up @@ -141,22 +149,32 @@ public static IResourceBuilder<OracleDatabaseServerResource> WithInitBindMount(t
}

/// <summary>
/// Copies init files into a Oracle Database server container resource.
/// Copies startup files into an Oracle Database server container resource.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="source">The source file or directory on the host to copy into the container.</param>
/// <param name="source">
/// The source file or directory on the host. A directory containing <c>startup</c> or <c>setup</c> subdirectories is copied
/// to Oracle's scripts directory for compatibility. Other sources are copied directly to the startup scripts directory.
/// </param>
/// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// Oracle executes scripts in the startup scripts directory each time the database container starts.
/// </remarks>
Comment on lines +160 to +162
/// <ats-returns>The resource builder.</ats-returns>
[AspireExport]
public static IResourceBuilder<OracleDatabaseServerResource> WithInitFiles(this IResourceBuilder<OracleDatabaseServerResource> builder, string source)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(source);

const string initPath = "/docker-entrypoint-initdb.d";

var importFullPath = Path.GetFullPath(source, builder.ApplicationBuilder.AppHostDirectory);

// WithInitFiles previously targeted Oracle's parent scripts directory. Preserve callers that adopted
// Oracle's startup/setup subdirectory layout while making flat files execute as startup scripts.
var usesOracleScriptsLayout = Directory.Exists(Path.Combine(importFullPath, "startup"))
|| Directory.Exists(Path.Combine(importFullPath, "setup"));
var initPath = usesOracleScriptsLayout ? "/opt/oracle/scripts" : "/opt/oracle/scripts/startup";

return builder.WithContainerFiles(initPath, importFullPath);
}

Expand All @@ -166,6 +184,12 @@ public static IResourceBuilder<OracleDatabaseServerResource> WithInitFiles(this
/// <param name="builder">The resource builder.</param>
/// <param name="source">The source directory on the host to mount into the container.</param>
/// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// Oracle executes setup scripts only when creating a database. Oracle Database Free images contain a pre-built database,
/// so configure an empty data volume or bind mount with <see cref="WithDataVolume"/> or <see cref="WithDataBindMount"/>
/// to create a new database and run the setup scripts.
/// </remarks>
/// <seealso href="https://container-registry.oracle.com/ords/ocr/ba/database/free">Oracle Database Free container image documentation</seealso>
/// <ats-returns>The resource builder.</ats-returns>
[AspireExport]
public static IResourceBuilder<OracleDatabaseServerResource> WithDbSetupBindMount(this IResourceBuilder<OracleDatabaseServerResource> builder, string source)
Expand Down
39 changes: 39 additions & 0 deletions src/Aspire.Hosting.Oracle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,44 @@ const myService = await builder.addNodeApp("myService", "../my-service", "server
.withReference(db);
```

## Initialization scripts

Use `WithInitFiles` to copy SQL or shell scripts that Oracle should execute each time the database container starts:

**C#**

```csharp
var oracle = builder.AddOracle("oracle")
.WithInitFiles("./oracle/startup");
```

**TypeScript**

```typescript
const oracle = await builder.addOracle("oracle")
.withInitFiles("./oracle/startup");
```

For compatibility, `WithInitFiles` also accepts a parent directory containing Oracle's `startup` or `setup` subdirectories. Aspire preserves that directory layout under `/opt/oracle/scripts`.

Use `WithDbSetupBindMount` for scripts that should run once while Oracle creates the database. Oracle Database Free images include a pre-built database, so setup scripts run only when the container starts with empty data storage. Configure a new data volume or an empty data bind mount together with the setup scripts:

**C#**

```csharp
var oracle = builder.AddOracle("oracle")
.WithDataVolume()
.WithDbSetupBindMount("./oracle/setup");
```

**TypeScript**

```typescript
const oracle = await builder.addOracle("oracle")
.withDataVolume()
.withDbSetupBindMount("./oracle/setup");
```

## Connection Properties

When you reference an Oracle database resource using `WithReference`, the following connection properties are made available to the consuming project:
Expand Down Expand Up @@ -67,6 +105,7 @@ Aspire exposes each property as an environment variable named `[RESOURCE]_[PROPE

https://aspire.dev/integrations/gallery/
https://aspire.dev/integrations/databases/efcore/oracle/oracle-host/
https://container-registry.oracle.com/ords/ocr/ba/database/free

## Feedback & contributing

Expand Down
85 changes: 85 additions & 0 deletions tests/Aspire.Hosting.Oracle.Tests/AddOracleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ public void AddOracleAddsGeneratedPasswordParameterWithUserSecretsParameterDefau
Assert.Equal("Aspire.Hosting.ApplicationModel.UserSecretsParameterDefault", orcl.Resource.PasswordParameter.Default?.GetType().FullName);
}

[Fact]
public void AddOracleGeneratesPasswordAcceptedByDatabaseCreationScripts()
{
using var appBuilder = TestDistributedApplicationBuilder.Create();

var orcl = appBuilder.AddOracle("orcl");
var password = orcl.Resource.PasswordParameter.Default!.GetDefaultValue();

Assert.Matches("^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])[a-zA-Z0-9]{22}$", password);
}

[Fact]
public void AddOracleDoesNotAddGeneratedPasswordParameterWithUserSecretsParameterDefaultInPublishMode()
{
Expand Down Expand Up @@ -258,6 +269,80 @@ public async Task VerifyManifestWithPasswordParameter()
Assert.Equal(expectedManifest, serverManifest.ToString());
}

[Fact]
public async Task WithInitFilesCopiesFilesToStartupDirectory()
{
var sourceDirectory = Directory.CreateTempSubdirectory();

try
{
var sourcePath = Path.Combine(sourceDirectory.FullName, "01_init.sql");
await File.WriteAllTextAsync(sourcePath, "SELECT 1 FROM DUAL;");

using var builder = TestDistributedApplicationBuilder.Create();
var oracle = builder.AddOracle("oracle")
.WithInitFiles(sourceDirectory.FullName);

var annotation = Assert.Single(oracle.Resource.Annotations.OfType<ContainerFileSystemCallbackAnnotation>());
Assert.Equal("/opt/oracle/scripts/startup", annotation.DestinationPath);
Assert.Null(annotation.DefaultOwner);
Assert.Null(annotation.DefaultGroup);
Assert.Null(annotation.Umask);

var entries = await annotation.Callback(new()
{
Services = TestServiceProvider.Instance,
Model = oracle.Resource
}, CancellationToken.None);

var file = Assert.IsType<ContainerFile>(Assert.Single(entries));
Assert.Equal("01_init.sql", file.Name);
Assert.Equal(sourcePath, file.SourcePath);
}
finally
{
sourceDirectory.Delete(recursive: true);
}
}

[Theory]
[InlineData("startup")]
[InlineData("setup")]
public void WithInitFilesPreservesOracleScriptsDirectoryLayout(string scriptsDirectory)
{
var sourceDirectory = Directory.CreateTempSubdirectory();

try
{
Directory.CreateDirectory(Path.Combine(sourceDirectory.FullName, scriptsDirectory));

using var builder = TestDistributedApplicationBuilder.Create();
var oracle = builder.AddOracle("oracle")
.WithInitFiles(sourceDirectory.FullName);

var annotation = Assert.Single(oracle.Resource.Annotations.OfType<ContainerFileSystemCallbackAnnotation>());
Assert.Equal("/opt/oracle/scripts", annotation.DestinationPath);
}
finally
{
sourceDirectory.Delete(recursive: true);
}
}

[Fact]
public void WithDbSetupBindMountAddsSetupDirectoryMount()
{
using var builder = TestDistributedApplicationBuilder.Create();
var oracle = builder.AddOracle("oracle")
.WithDbSetupBindMount("setup");

var mount = Assert.Single(oracle.Resource.Annotations.OfType<ContainerMountAnnotation>());
Assert.Equal(Path.Combine(builder.AppHostDirectory, "setup"), mount.Source);
Assert.Equal("/opt/oracle/scripts/setup", mount.Target);
Assert.Equal(ContainerMountType.BindMount, mount.Type);
Assert.False(mount.IsReadOnly);
}

[Fact]
public void ThrowsWithIdenticalChildResourceNames()
{
Expand Down
75 changes: 57 additions & 18 deletions tests/Aspire.Hosting.Oracle.Tests/OracleFunctionalTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -345,14 +345,26 @@ await pipeline.ExecuteAsync(async token =>
}

[Theory]
[InlineData(false)]
[InlineData(true)]
[InlineData(false, Skip = "https://github.com/microsoft/aspire/issues/5190")]
[RequiresFeature(TestFeature.ContainerRuntime)]
public async Task VerifyWithInitFiles(bool init)
public Task VerifyWithInitFiles(bool useOracleScriptsLayout)
{
return VerifyInitializationFiles(useSetupBindMount: false, useOracleScriptsLayout: useOracleScriptsLayout);
}

[Fact]
[RequiresFeature(TestFeature.ContainerRuntime)]
public Task VerifyWithDbSetupBindMount()
{
return VerifyInitializationFiles(useSetupBindMount: true, useOracleScriptsLayout: false);
}

private async Task VerifyInitializationFiles(bool useSetupBindMount, bool useOracleScriptsLayout)
{
// Creates a script that should be executed when the container is initialized.

var cts = new CancellationTokenSource(TimeSpan.FromMinutes(15));
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(15));
var pipeline = new ResiliencePipelineBuilder()
.AddRetry(new()
{
Expand All @@ -364,12 +376,26 @@ public async Task VerifyWithInitFiles(bool init)
.Build();

var initFilesPath = Directory.CreateTempSubdirectory().FullName;
var dataPath = useSetupBindMount ? Directory.CreateTempSubdirectory().FullName : null;
var scriptPath = useOracleScriptsLayout
? Directory.CreateDirectory(Path.Combine(initFilesPath, "startup")).FullName
: initFilesPath;

if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(initFilesPath, MountFilePermissions);

if (dataPath is not null)
{
File.SetUnixFileMode(dataPath, MountFilePermissions);
}
}

var oracleDbName = "freepdb1";

try
{
File.WriteAllText(Path.Combine(initFilesPath, "01_init.sql"), $"""
File.WriteAllText(Path.Combine(scriptPath, "01_init.sql"), $"""
ALTER SESSION SET CONTAINER={oracleDbName};
ALTER SESSION SET CURRENT_SCHEMA = SYSTEM;
CREATE TABLE "Cars" ("Id" NUMBER(10) GENERATED BY DEFAULT ON NULL AS IDENTITY NOT NULL, "Brand" NVARCHAR2(2000) NOT NULL, CONSTRAINT "PK_Cars" PRIMARY KEY ("Id") );
Expand All @@ -382,22 +408,21 @@ public async Task VerifyWithInitFiles(bool init)
var oracle = builder.AddOracle("oracle");
var db = oracle.AddDatabase(oracleDbName);

var ready = builder;

if (init)
if (useSetupBindMount)
{
oracle.WithInitFiles(initFilesPath);
oracle.WithDbSetupBindMount(initFilesPath)
.WithDataBindMount(dataPath!);
}
else
{
oracle.WithDbSetupBindMount(initFilesPath);
oracle.WithInitFiles(initFilesPath);
}

using var app = builder.Build();

await app.StartAsync();

await app.WaitForTextAsync(DatabaseReadyText, cancellationToken: cts.Token);
await app.WaitForTextAsync("DONE: Executing user defined scripts", cancellationToken: cts.Token);

var hb = Host.CreateApplicationBuilder();

Expand Down Expand Up @@ -430,14 +455,8 @@ await pipeline.ExecuteAsync(async token =>
}
finally
{
try
{
Directory.Delete(initFilesPath, true);
}
catch
{
// Don't fail test if we can't clean the temporary folder
}
TryDeleteDirectory(initFilesPath);
TryDeleteDirectory(dataPath);
}
}

Expand Down Expand Up @@ -488,4 +507,24 @@ public Task Oracle_WithPersistentLifetime_ReusesContainer()
"resource");
}

private static void TryDeleteDirectory(string? path)
{
if (path is null)
{
return;
}

try
{
Directory.Delete(path, recursive: true);
}
catch (IOException)
{
// Container runtimes can briefly retain handles to bind-mounted directories during teardown.
}
catch (UnauthorizedAccessException)
{
// The Oracle container can leave database files owned by its non-root user.
}
}
}
Loading