Skip to content

Commit 3f163aa

Browse files
Fix PostgreSQL v18+ data volume path (#14202)
PostgreSQL 18+ changed the default data directory from /var/lib/postgresql/data to /var/lib/postgresql. This commit updates WithDataVolume and WithDataBindMount to automatically detect the PostgreSQL version from the container image tag and use the appropriate data directory path. - Added GetPostgresDataDirectoryPath helper method - Added TryParsePostgresMajorVersion helper method - Updated WithDataVolume to use version-aware path - Updated WithDataBindMount to use version-aware path - Added comprehensive unit tests for version parsing and path selection Fixes #13792
1 parent b9d318e commit 3f163aa

2 files changed

Lines changed: 240 additions & 2 deletions

File tree

src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -422,12 +422,26 @@ private static void SetPgAdminEnvironmentVariables(EnvironmentCallbackContext co
422422
/// <param name="name">The name of the volume. Defaults to an auto-generated name based on the application and resource names.</param>
423423
/// <param name="isReadOnly">A flag that indicates if this is a read-only volume.</param>
424424
/// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
425+
/// <remarks>
426+
/// <para>
427+
/// The data directory location varies by PostgreSQL version:
428+
/// </para>
429+
/// <list type="bullet">
430+
/// <item><description>PostgreSQL 17 and earlier: <c>/var/lib/postgresql/data</c></description></item>
431+
/// <item><description>PostgreSQL 18 and later: <c>/var/lib/postgresql</c></description></item>
432+
/// </list>
433+
/// <para>
434+
/// This method automatically selects the correct path based on the configured container image tag.
435+
/// </para>
436+
/// </remarks>
425437
public static IResourceBuilder<PostgresServerResource> WithDataVolume(this IResourceBuilder<PostgresServerResource> builder, string? name = null, bool isReadOnly = false)
426438
{
427439
ArgumentNullException.ThrowIfNull(builder);
428440

441+
var dataPath = GetPostgresDataDirectoryPath(builder);
442+
429443
return builder.WithVolume(name ?? VolumeNameGenerator.Generate(builder, "data"),
430-
"/var/lib/postgresql/data", isReadOnly);
444+
dataPath, isReadOnly);
431445
}
432446

433447
/// <summary>
@@ -437,12 +451,26 @@ public static IResourceBuilder<PostgresServerResource> WithDataVolume(this IReso
437451
/// <param name="source">The source directory on the host to mount into the container.</param>
438452
/// <param name="isReadOnly">A flag that indicates if this is a read-only mount.</param>
439453
/// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
454+
/// <remarks>
455+
/// <para>
456+
/// The data directory location varies by PostgreSQL version:
457+
/// </para>
458+
/// <list type="bullet">
459+
/// <item><description>PostgreSQL 17 and earlier: <c>/var/lib/postgresql/data</c></description></item>
460+
/// <item><description>PostgreSQL 18 and later: <c>/var/lib/postgresql</c></description></item>
461+
/// </list>
462+
/// <para>
463+
/// This method automatically selects the correct path based on the configured container image tag.
464+
/// </para>
465+
/// </remarks>
440466
public static IResourceBuilder<PostgresServerResource> WithDataBindMount(this IResourceBuilder<PostgresServerResource> builder, string source, bool isReadOnly = false)
441467
{
442468
ArgumentNullException.ThrowIfNull(builder);
443469
ArgumentException.ThrowIfNullOrEmpty(source);
444470

445-
return builder.WithBindMount(source, "/var/lib/postgresql/data", isReadOnly);
471+
var dataPath = GetPostgresDataDirectoryPath(builder);
472+
473+
return builder.WithBindMount(source, dataPath, isReadOnly);
446474
}
447475

448476
/// <summary>
@@ -620,6 +648,51 @@ private static async Task<string> WritePgAdminServerJson(IEnumerable<PostgresSer
620648
return Encoding.UTF8.GetString(stream.ToArray());
621649
}
622650

651+
/// <summary>
652+
/// Gets the appropriate PostgreSQL data directory path based on the image version.
653+
/// </summary>
654+
/// <remarks>
655+
/// PostgreSQL 18+ changed the data directory from /var/lib/postgresql/data to /var/lib/postgresql.
656+
/// See https://github.com/docker-library/postgres/pull/1259 for more information.
657+
/// </remarks>
658+
internal static string GetPostgresDataDirectoryPath(IResourceBuilder<PostgresServerResource> builder)
659+
{
660+
if (builder.Resource.Annotations.OfType<ContainerImageAnnotation>().LastOrDefault() is { } imageAnnotation)
661+
{
662+
var tag = imageAnnotation.Tag ?? PostgresContainerImageTags.Tag;
663+
if (TryParsePostgresMajorVersion(tag, out var majorVersion) && majorVersion >= 18)
664+
{
665+
return "/var/lib/postgresql";
666+
}
667+
}
668+
669+
return "/var/lib/postgresql/data";
670+
}
671+
672+
/// <summary>
673+
/// Attempts to parse the PostgreSQL major version from an image tag.
674+
/// </summary>
675+
/// <param name="tag">The image tag (e.g., "17.6", "18.1", "18-alpine", "latest").</param>
676+
/// <param name="majorVersion">The parsed major version number, if successful.</param>
677+
/// <returns><see langword="true"/> if the major version was successfully parsed; otherwise, <see langword="false"/>.</returns>
678+
internal static bool TryParsePostgresMajorVersion(string tag, out int majorVersion)
679+
{
680+
majorVersion = 0;
681+
682+
if (string.IsNullOrWhiteSpace(tag))
683+
{
684+
return false;
685+
}
686+
687+
// Handle tags like "18.1-alpine", "17.6-bookworm", etc.
688+
var versionPart = tag.Split('-')[0];
689+
690+
// Handle tags like "18.1", "17", etc.
691+
var parts = versionPart.Split('.');
692+
693+
return parts.Length > 0 && int.TryParse(parts[0], out majorVersion) && majorVersion > 0;
694+
}
695+
623696
private static async Task CreateDatabaseAsync(NpgsqlConnection npgsqlConnection, PostgresDatabaseResource npgsqlDatabase, IServiceProvider serviceProvider, CancellationToken cancellationToken)
624697
{
625698
var scriptAnnotation = npgsqlDatabase.Annotations.OfType<PostgresCreateDatabaseScriptAnnotation>().LastOrDefault();

tests/Aspire.Hosting.PostgreSQL.Tests/AddPostgresTests.cs

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,4 +706,169 @@ public async Task PostgresEnvironmentCallbackIsIdempotent()
706706
Assert.Equal(kvp.Value, config2[kvp.Key]);
707707
});
708708
}
709+
710+
[Theory]
711+
[InlineData("17.6", 17)]
712+
[InlineData("18.1", 18)]
713+
[InlineData("18", 18)]
714+
[InlineData("18-alpine", 18)]
715+
[InlineData("17.6-bookworm", 17)]
716+
[InlineData("16.0", 16)]
717+
[InlineData("9.6", 9)]
718+
public void TryParsePostgresMajorVersionReturnsTrueForValidTags(string tag, int expectedMajorVersion)
719+
{
720+
var result = PostgresBuilderExtensions.TryParsePostgresMajorVersion(tag, out var majorVersion);
721+
722+
Assert.True(result);
723+
Assert.Equal(expectedMajorVersion, majorVersion);
724+
}
725+
726+
[Theory]
727+
[InlineData("latest")]
728+
[InlineData("alpine")]
729+
[InlineData("")]
730+
[InlineData(" ")]
731+
[InlineData("abc")]
732+
public void TryParsePostgresMajorVersionReturnsFalseForInvalidTags(string tag)
733+
{
734+
var result = PostgresBuilderExtensions.TryParsePostgresMajorVersion(tag, out var majorVersion);
735+
736+
Assert.False(result);
737+
Assert.Equal(0, majorVersion);
738+
}
739+
740+
[Theory]
741+
[InlineData(null)]
742+
[InlineData(true)]
743+
[InlineData(false)]
744+
public void WithDataVolumeUsesLegacyPathForPostgres17(bool? isReadOnly)
745+
{
746+
using var builder = TestDistributedApplicationBuilder.Create();
747+
var postgres = builder.AddPostgres("myPostgres");
748+
749+
// Default image is v17.x, so should use legacy path
750+
if (isReadOnly.HasValue)
751+
{
752+
postgres.WithDataVolume(isReadOnly: isReadOnly.Value);
753+
}
754+
else
755+
{
756+
postgres.WithDataVolume();
757+
}
758+
759+
var volumeAnnotation = postgres.Resource.Annotations.OfType<ContainerMountAnnotation>().Single();
760+
761+
Assert.Equal($"{builder.GetVolumePrefix()}-myPostgres-data", volumeAnnotation.Source);
762+
Assert.Equal("/var/lib/postgresql/data", volumeAnnotation.Target);
763+
Assert.Equal(ContainerMountType.Volume, volumeAnnotation.Type);
764+
Assert.Equal(isReadOnly ?? false, volumeAnnotation.IsReadOnly);
765+
}
766+
767+
[Theory]
768+
[InlineData(null)]
769+
[InlineData(true)]
770+
[InlineData(false)]
771+
public void WithDataVolumeUsesNewPathForPostgres18(bool? isReadOnly)
772+
{
773+
using var builder = TestDistributedApplicationBuilder.Create();
774+
var postgres = builder.AddPostgres("myPostgres")
775+
.WithImage("postgres", "18.1");
776+
777+
if (isReadOnly.HasValue)
778+
{
779+
postgres.WithDataVolume(isReadOnly: isReadOnly.Value);
780+
}
781+
else
782+
{
783+
postgres.WithDataVolume();
784+
}
785+
786+
var volumeAnnotation = postgres.Resource.Annotations.OfType<ContainerMountAnnotation>().Single();
787+
788+
Assert.Equal($"{builder.GetVolumePrefix()}-myPostgres-data", volumeAnnotation.Source);
789+
Assert.Equal("/var/lib/postgresql", volumeAnnotation.Target);
790+
Assert.Equal(ContainerMountType.Volume, volumeAnnotation.Type);
791+
Assert.Equal(isReadOnly ?? false, volumeAnnotation.IsReadOnly);
792+
}
793+
794+
[Fact]
795+
public void WithDataVolumeUsesNewPathForPostgres18Alpine()
796+
{
797+
using var builder = TestDistributedApplicationBuilder.Create();
798+
var postgres = builder.AddPostgres("myPostgres")
799+
.WithImage("postgres", "18-alpine")
800+
.WithDataVolume();
801+
802+
var volumeAnnotation = postgres.Resource.Annotations.OfType<ContainerMountAnnotation>().Single();
803+
804+
Assert.Equal("/var/lib/postgresql", volumeAnnotation.Target);
805+
}
806+
807+
[Fact]
808+
public void WithDataVolumeUsesLegacyPathForUnparsableTag()
809+
{
810+
using var builder = TestDistributedApplicationBuilder.Create();
811+
var postgres = builder.AddPostgres("myPostgres")
812+
.WithImage("postgres", "latest")
813+
.WithDataVolume();
814+
815+
var volumeAnnotation = postgres.Resource.Annotations.OfType<ContainerMountAnnotation>().Single();
816+
817+
// When tag can't be parsed, fall back to legacy path for safety
818+
Assert.Equal("/var/lib/postgresql/data", volumeAnnotation.Target);
819+
}
820+
821+
[Theory]
822+
[InlineData(null)]
823+
[InlineData(true)]
824+
[InlineData(false)]
825+
public void WithDataBindMountUsesLegacyPathForPostgres17(bool? isReadOnly)
826+
{
827+
using var builder = TestDistributedApplicationBuilder.Create();
828+
var postgres = builder.AddPostgres("myPostgres");
829+
830+
// Default image is v17.x, so should use legacy path
831+
if (isReadOnly.HasValue)
832+
{
833+
postgres.WithDataBindMount("mydata", isReadOnly: isReadOnly.Value);
834+
}
835+
else
836+
{
837+
postgres.WithDataBindMount("mydata");
838+
}
839+
840+
var volumeAnnotation = postgres.Resource.Annotations.OfType<ContainerMountAnnotation>().Single();
841+
842+
Assert.Equal(Path.Combine(builder.AppHostDirectory, "mydata"), volumeAnnotation.Source);
843+
Assert.Equal("/var/lib/postgresql/data", volumeAnnotation.Target);
844+
Assert.Equal(ContainerMountType.BindMount, volumeAnnotation.Type);
845+
Assert.Equal(isReadOnly ?? false, volumeAnnotation.IsReadOnly);
846+
}
847+
848+
[Theory]
849+
[InlineData(null)]
850+
[InlineData(true)]
851+
[InlineData(false)]
852+
public void WithDataBindMountUsesNewPathForPostgres18(bool? isReadOnly)
853+
{
854+
using var builder = TestDistributedApplicationBuilder.Create();
855+
var postgres = builder.AddPostgres("myPostgres")
856+
.WithImage("postgres", "18.1");
857+
858+
if (isReadOnly.HasValue)
859+
{
860+
postgres.WithDataBindMount("mydata", isReadOnly: isReadOnly.Value);
861+
}
862+
else
863+
{
864+
postgres.WithDataBindMount("mydata");
865+
}
866+
867+
var volumeAnnotation = postgres.Resource.Annotations.OfType<ContainerMountAnnotation>().Single();
868+
869+
Assert.Equal(Path.Combine(builder.AppHostDirectory, "mydata"), volumeAnnotation.Source);
870+
Assert.Equal("/var/lib/postgresql", volumeAnnotation.Target);
871+
Assert.Equal(ContainerMountType.BindMount, volumeAnnotation.Type);
872+
Assert.Equal(isReadOnly ?? false, volumeAnnotation.IsReadOnly);
873+
}
709874
}

0 commit comments

Comments
 (0)