From 299046bdd4922b91c3c9cef978803e9445a4286d Mon Sep 17 00:00:00 2001 From: Matt Morrison <3241034+Emdot@users.noreply.github.com> Date: Sun, 21 Feb 2021 15:43:42 -0600 Subject: [PATCH 1/3] Start of the new user layer. Adds the client, connection to daemons, and authentication with registries. --- .dockerignore | 25 ++ .editorconfig | 31 +++ CodeMaid.config | 73 +++++ Docker.DotNet.sln | 39 ++- .../DockerClientConfiguration.cs | 4 +- src/DockerSdk/.editorconfig | 123 +++++++++ src/DockerSdk/CertificateLoader.cs | 20 ++ src/DockerSdk/ClientOptions.cs | 109 ++++++++ src/DockerSdk/DockerClient.cs | 203 ++++++++++++++ src/DockerSdk/DockerException.cs | 50 ++++ src/DockerSdk/DockerSdk.csproj | 21 ++ src/DockerSdk/DockerVersionException.cs | 28 ++ src/DockerSdk/Properties/TestAccess.cs | 2 + src/DockerSdk/Registries/RegistryAccess.cs | 254 ++++++++++++++++++ src/DockerSdk/Registries/RegistryEntry.cs | 56 ++++ test/DockerSdk.Tests/.editorconfig | 123 +++++++++ test/DockerSdk.Tests/Cli.cs | 112 ++++++++ test/DockerSdk.Tests/ClientTests.cs | 34 +++ test/DockerSdk.Tests/ClientUnitTests.cs | 125 +++++++++ test/DockerSdk.Tests/DockerSdk.Tests.csproj | 39 +++ test/DockerSdk.Tests/Fixture.cs | 26 ++ test/DockerSdk.Tests/RegistryAccessTests.cs | 110 ++++++++ .../RegistryAccessUnitTests.cs | 104 +++++++ .../scripts/docker-compose.yml | 18 ++ .../scripts/registry-htpasswd-tls/Dockerfile | 22 ++ .../scripts/registry-open/Dockerfile | 3 + 26 files changed, 1750 insertions(+), 4 deletions(-) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 CodeMaid.config create mode 100644 src/DockerSdk/.editorconfig create mode 100644 src/DockerSdk/CertificateLoader.cs create mode 100644 src/DockerSdk/ClientOptions.cs create mode 100644 src/DockerSdk/DockerClient.cs create mode 100644 src/DockerSdk/DockerException.cs create mode 100644 src/DockerSdk/DockerSdk.csproj create mode 100644 src/DockerSdk/DockerVersionException.cs create mode 100644 src/DockerSdk/Properties/TestAccess.cs create mode 100644 src/DockerSdk/Registries/RegistryAccess.cs create mode 100644 src/DockerSdk/Registries/RegistryEntry.cs create mode 100644 test/DockerSdk.Tests/.editorconfig create mode 100644 test/DockerSdk.Tests/Cli.cs create mode 100644 test/DockerSdk.Tests/ClientTests.cs create mode 100644 test/DockerSdk.Tests/ClientUnitTests.cs create mode 100644 test/DockerSdk.Tests/DockerSdk.Tests.csproj create mode 100644 test/DockerSdk.Tests/Fixture.cs create mode 100644 test/DockerSdk.Tests/RegistryAccessTests.cs create mode 100644 test/DockerSdk.Tests/RegistryAccessUnitTests.cs create mode 100644 test/DockerSdk.Tests/scripts/docker-compose.yml create mode 100644 test/DockerSdk.Tests/scripts/registry-htpasswd-tls/Dockerfile create mode 100644 test/DockerSdk.Tests/scripts/registry-open/Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..3729ff0cd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..7bae3b1db --- /dev/null +++ b/.editorconfig @@ -0,0 +1,31 @@ +# To learn more about .editorconfig see https://aka.ms/editorconfigdocs + +############################### +# Core EditorConfig Options # +############################### + +root = true + +# All files +[*] +indent_style = space + +# Code files +[*.cs] +indent_size = 4 +insert_final_newline = true +charset = utf-8-bom + +# Dockerfiles +[Dockerfile] +indent_size = 2 + +# YAML +[*.yml] +indent_size = 2 + +# Scripts +[*.ps1] +indent_size = 4 +insert_final_newline = true +charset = utf-8-bom diff --git a/CodeMaid.config b/CodeMaid.config new file mode 100644 index 000000000..39f9eb882 --- /dev/null +++ b/CodeMaid.config @@ -0,0 +1,73 @@ + + + + +
+ + + + + + \.Designer\.cs$||\.Designer\.vb$||\.resx$||\.min\.css$||\.min\.js$||\.generated\.cs$||\.generated\.vb$ + + + True + + + False + + + True + + + Structs||7||Structs + Enums + Classes + Interfaces + + + Constructors||1||Constructors + + + Methods||6||Methods + + + Properties||4||Properties + Indexers + + + Enums||7||Structs + Enums + Classes + Interfaces + + + Destructors||8||Destructors + + + Delegates||2||Delegates + + + Indexers||4||Properties + Indexers + + + Classes||7||Structs + Enums + Classes + Interfaces + + + Fields||5||Fields + + + Interfaces||7||Structs + Enums + Classes + Interfaces + + + Events||3||Events + + + 1 + + + True + + + 120 + + + False + + + + \ No newline at end of file diff --git a/Docker.DotNet.sln b/Docker.DotNet.sln index 4f5c83527..d3559c506 100644 --- a/Docker.DotNet.sln +++ b/Docker.DotNet.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26228.9 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30804.86 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{85990620-78A6-4381-8BD6-84E6D0CF0649}" EndProject @@ -15,6 +15,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Docker.DotNet.X509", "src\D EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Docker.DotNet.Tests", "test\Docker.DotNet.Tests\Docker.DotNet.Tests.csproj", "{248C5D51-2B33-4A06-A0EA-AA709F752E52}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DockerSdk", "src\DockerSdk\DockerSdk.csproj", "{2E0D6ACC-A935-465E-ADD3-E06A6A426408}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DockerSdk.Tests", "test\DockerSdk.Tests\DockerSdk.Tests.csproj", "{94D3A9F4-36DD-4104-8919-621C3B2ECB99}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{3FC99294-813B-450A-9DD2-7D3126B17A55}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -73,6 +79,30 @@ Global {248C5D51-2B33-4A06-A0EA-AA709F752E52}.Release|x64.Build.0 = Release|Any CPU {248C5D51-2B33-4A06-A0EA-AA709F752E52}.Release|x86.ActiveCfg = Release|Any CPU {248C5D51-2B33-4A06-A0EA-AA709F752E52}.Release|x86.Build.0 = Release|Any CPU + {2E0D6ACC-A935-465E-ADD3-E06A6A426408}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2E0D6ACC-A935-465E-ADD3-E06A6A426408}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2E0D6ACC-A935-465E-ADD3-E06A6A426408}.Debug|x64.ActiveCfg = Debug|Any CPU + {2E0D6ACC-A935-465E-ADD3-E06A6A426408}.Debug|x64.Build.0 = Debug|Any CPU + {2E0D6ACC-A935-465E-ADD3-E06A6A426408}.Debug|x86.ActiveCfg = Debug|Any CPU + {2E0D6ACC-A935-465E-ADD3-E06A6A426408}.Debug|x86.Build.0 = Debug|Any CPU + {2E0D6ACC-A935-465E-ADD3-E06A6A426408}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2E0D6ACC-A935-465E-ADD3-E06A6A426408}.Release|Any CPU.Build.0 = Release|Any CPU + {2E0D6ACC-A935-465E-ADD3-E06A6A426408}.Release|x64.ActiveCfg = Release|Any CPU + {2E0D6ACC-A935-465E-ADD3-E06A6A426408}.Release|x64.Build.0 = Release|Any CPU + {2E0D6ACC-A935-465E-ADD3-E06A6A426408}.Release|x86.ActiveCfg = Release|Any CPU + {2E0D6ACC-A935-465E-ADD3-E06A6A426408}.Release|x86.Build.0 = Release|Any CPU + {94D3A9F4-36DD-4104-8919-621C3B2ECB99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {94D3A9F4-36DD-4104-8919-621C3B2ECB99}.Debug|Any CPU.Build.0 = Debug|Any CPU + {94D3A9F4-36DD-4104-8919-621C3B2ECB99}.Debug|x64.ActiveCfg = Debug|Any CPU + {94D3A9F4-36DD-4104-8919-621C3B2ECB99}.Debug|x64.Build.0 = Debug|Any CPU + {94D3A9F4-36DD-4104-8919-621C3B2ECB99}.Debug|x86.ActiveCfg = Debug|Any CPU + {94D3A9F4-36DD-4104-8919-621C3B2ECB99}.Debug|x86.Build.0 = Debug|Any CPU + {94D3A9F4-36DD-4104-8919-621C3B2ECB99}.Release|Any CPU.ActiveCfg = Release|Any CPU + {94D3A9F4-36DD-4104-8919-621C3B2ECB99}.Release|Any CPU.Build.0 = Release|Any CPU + {94D3A9F4-36DD-4104-8919-621C3B2ECB99}.Release|x64.ActiveCfg = Release|Any CPU + {94D3A9F4-36DD-4104-8919-621C3B2ECB99}.Release|x64.Build.0 = Release|Any CPU + {94D3A9F4-36DD-4104-8919-621C3B2ECB99}.Release|x86.ActiveCfg = Release|Any CPU + {94D3A9F4-36DD-4104-8919-621C3B2ECB99}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -82,5 +112,10 @@ Global {E1F24B25-E027-45E0-A6E1-E08138F1F95D} = {85990620-78A6-4381-8BD6-84E6D0CF0649} {89BD76AD-78C9-4E4A-96A2-E5DA6D4AFA44} = {85990620-78A6-4381-8BD6-84E6D0CF0649} {248C5D51-2B33-4A06-A0EA-AA709F752E52} = {AA4B8CC2-1431-4FC7-9DF3-533EC6C86D3A} + {2E0D6ACC-A935-465E-ADD3-E06A6A426408} = {85990620-78A6-4381-8BD6-84E6D0CF0649} + {94D3A9F4-36DD-4104-8919-621C3B2ECB99} = {AA4B8CC2-1431-4FC7-9DF3-533EC6C86D3A} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {2FB3FE97-BC33-411B-92BB-69801DD461D1} EndGlobalSection EndGlobal diff --git a/src/Docker.DotNet/DockerClientConfiguration.cs b/src/Docker.DotNet/DockerClientConfiguration.cs index 9417d1f00..32a58c98c 100644 --- a/src/Docker.DotNet/DockerClientConfiguration.cs +++ b/src/Docker.DotNet/DockerClientConfiguration.cs @@ -14,7 +14,7 @@ public class DockerClientConfiguration : IDisposable public TimeSpan NamedPipeConnectTimeout { get; set; } = TimeSpan.FromMilliseconds(100); - private static Uri LocalDockerUri() + public static Uri LocalDockerUri() { var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); return isWindows ? new Uri("npipe://./pipe/docker_engine") : new Uri("unix:/var/run/docker.sock"); @@ -59,4 +59,4 @@ public void Dispose() Credentials.Dispose(); } } -} \ No newline at end of file +} diff --git a/src/DockerSdk/.editorconfig b/src/DockerSdk/.editorconfig new file mode 100644 index 000000000..b82d1b0e9 --- /dev/null +++ b/src/DockerSdk/.editorconfig @@ -0,0 +1,123 @@ +# To learn more about .editorconfig see https://aka.ms/editorconfigdocs + +############################### +# Core EditorConfig Options # +############################### + +# inherited + +############################### +# .NET Coding Conventions # +############################### + +[*.cs] + +# Organize usings +dotnet_sort_system_directives_first = true +# this. preferences +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_property = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_event = false:silent +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent +dotnet_style_readonly_field = true:suggestion +# Expression-level preferences +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent + +############################### +# Naming Conventions # +############################### + +# Style Definitions +dotnet_naming_style.pascal_case_style.capitalization = pascal_case +# Use PascalCase for constant fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.applicable_accessibilities = * +dotnet_naming_symbols.constant_fields.required_modifiers = const + +############################### +# C# Coding Conventions # +############################### + +[*.cs] + +# var preferences +csharp_style_var_for_built_in_types = true:silent +csharp_style_var_when_type_is_apparent = true:silent +csharp_style_var_elsewhere = true:silent +# Expression-bodied members +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent +# Pattern matching preferences +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +# Null-checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion +# Modifier preferences +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion +# Expression-level preferences +csharp_prefer_braces = true:silent +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion + +############################### +# C# Formatting Rules # +############################### + +# New line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true +# Indentation preferences +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left +# Space preferences +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_around_binary_operators = before_and_after +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +# Wrapping preferences +csharp_preserve_single_line_statements = true +csharp_preserve_single_line_blocks = true + diff --git a/src/DockerSdk/CertificateLoader.cs b/src/DockerSdk/CertificateLoader.cs new file mode 100644 index 000000000..5efc7f215 --- /dev/null +++ b/src/DockerSdk/CertificateLoader.cs @@ -0,0 +1,20 @@ +using System.Security.Cryptography.X509Certificates; + +namespace DockerSdk +{ + internal static class CertificateLoader + { + /// + /// Loads certificates from a file. + /// + /// The file to load from. + /// The resultant collection of certificates. + /// The file did not represent a valid certificate. + public static X509Certificate2Collection Load(string path) + { + var collection = new X509Certificate2Collection(); + collection.Import(path); + return collection; + } + } +} diff --git a/src/DockerSdk/ClientOptions.cs b/src/DockerSdk/ClientOptions.cs new file mode 100644 index 000000000..1685832c2 --- /dev/null +++ b/src/DockerSdk/ClientOptions.cs @@ -0,0 +1,109 @@ +using System; +using System.IO; +using System.Security.Cryptography.X509Certificates; +using Api = Docker.DotNet; + +namespace DockerSdk +{ + /// + /// Specifies where to find a Docker daemon and how the SDK should connect to it. + /// + /// + public class ClientOptions + { + /// + /// Gets or sets the set of certificates to use when communicating with the daemon. + /// + public X509Certificate2Collection Certificates { get; set; } = new X509Certificate2Collection(); + + /// + /// Gets or sets the credentials to use for connecting to the Docker daemon. + /// + public Api.Credentials Credentials { get; set; } + + /// + /// Gets or sets the Docker daemon URL to connect to. The default is localhost using a platform-appropriate + /// transport. + /// + public Uri DaemonUri { get; set; } = Api.DockerClientConfiguration.LocalDockerUri(); + + /// + /// Gets or sets how long the SDK should wait for responses to messages it sends to the Docker daemon. + /// + /// Some SDK methods override this value. + public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(60); + + /// + /// Gets or sets a value indicating whether to use TLS for communications with the daemon. + /// Defaults to . + /// + public bool UseTls { get; set; } = true; + + /// + /// Generates a object based on the local machine's environment variables. + /// + /// The generated . + /// + /// If any of the relevant environment variables are not set, the object will use + /// the default value for its corresponding property. Note that the default for / + /// DOCKER_HOST is not valid for connecting to a Docker daemon.
This method uses the following + /// environment variables: + /// + /// + /// DOCKER_HOST: The URL for the Docker daemon to connect to. Corresponds to the + /// property. + /// + /// + /// DOCKER_CERT_PATH: A filesystem path to read certificates from. Corresponds to the property. + /// + /// + /// DOCKER_TLS_VERIFY: If set, the connection will use TLS. Corresponds to the + /// property. + /// + /// + /// COMPOSER_HTTP_TIMEOUT: The communications timeout to use, in seconds. Corresponds to the property. + /// + /// + ///
+ public static ClientOptions FromEnvironment() + { + var output = new ClientOptions(); + + var daemonUriString = Environment.GetEnvironmentVariable("DOCKER_HOST"); + if (!string.IsNullOrEmpty(daemonUriString) && Uri.TryCreate(daemonUriString, UriKind.Absolute, out Uri daemonUri)) + { + output.DaemonUri = daemonUri; + } + + var certPath = Environment.GetEnvironmentVariable("DOCKER_CERT_PATH"); + if (!string.IsNullOrEmpty(certPath) && Directory.Exists(certPath)) + { + output.Certificates = CertificateLoader.Load(certPath); + } + + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DOCKER_TLS_VERIFY"))) + { + output.UseTls = true; + } + + var timeoutStringInSeconds = Environment.GetEnvironmentVariable("COMPOSER_HTTP_TIMEOUT"); + if (!string.IsNullOrEmpty(timeoutStringInSeconds) && double.TryParse(timeoutStringInSeconds, out double timeoutInSeconds)) + { + output.DefaultTimeout = TimeSpan.FromSeconds(timeoutInSeconds); + } + + return output; + } + + /// + /// Creates a client configuration object for use by the underlying .NET Docker API. + /// + /// An equivalent object. + internal Api.DockerClientConfiguration ToCore() => + new Api.DockerClientConfiguration(DaemonUri, Credentials, DefaultTimeout); + } +} diff --git a/src/DockerSdk/DockerClient.cs b/src/DockerSdk/DockerClient.cs new file mode 100644 index 000000000..c7fdd640a --- /dev/null +++ b/src/DockerSdk/DockerClient.cs @@ -0,0 +1,203 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using DockerSdk.Registries; + +using Core = Docker.DotNet; +using CoreModels = Docker.DotNet.Models; + +namespace DockerSdk +{ + /// + /// Provides remote access to a Docker daemon. + /// + public class DockerClient : IDisposable + { + private DockerClient(Core.DockerClient core, ClientOptions options, Version negotiatedApiVersion) + { + Core = core; + Options = options; + ApiVersion = negotiatedApiVersion; + Registries = new RegistryAccess(this); + } + + /// + /// Gets the version of the Docker API that will be used to communicate with the Docker daemon. + /// + /// This will always be the highest version that both sides support. + public Version ApiVersion { get; } + + /// + /// Provides access to functionality related to Docker registries. + /// + public RegistryAccess Registries { get; } + + /// + /// Gets the core client, which is what does all the heavy lifting for communicating with the Docker daemon. + /// + internal Core.IDockerClient Core { get; } + + internal ClientOptions Options { get; } + + /// + /// The minimum Docker API version that the SDK supports. + /// + private static readonly Version _libraryMaxApiVersion = new Version("1.41"); + + /// + /// The maximum Docker API version that the SDK supports. + /// + private static readonly Version _libraryMinApiVersion = new Version("1.41"); + + private bool _isDisposed; + + /// + /// Creates a new Docker client and connects it to the local Docker daemon. + /// + /// A token used to cancel the operation. + /// A that completes when the connection has been established. + /// + /// The API versions that the SDK supports don't overlap with the API versions that the daemon supports. + /// + /// An internal error occurred within the daemon. + /// + /// The request failed due to an underlying issue such as network connectivity, DNS failure, server certificate + /// validation, or timeout. + /// + public static Task StartAsync(CancellationToken ct = default) + => StartAsync(new ClientOptions(), ct); + + /// + /// Creates a new Docker client and connects it to a Docker daemon. + /// + /// The URL of the Docker daemon to connect to. + /// A token used to cancel the operation. + /// A that completes when the connection has been established. + /// + /// The API versions that the SDK supports don't overlap with the API versions that the daemon supports. + /// + /// An internal error occurred within the daemon. + /// + /// The request failed due to an underlying issue such as network connectivity, DNS failure, server certificate + /// validation, or timeout. + /// + /// The URL is . + public static Task StartAsync(Uri daemonUrl, CancellationToken ct = default) + => StartAsync(new ClientOptions { DaemonUri = daemonUrl }, ct); + + /// + /// Creates a new Docker client and connects it to a Docker daemon. + /// + /// Details on how to connect and how the client should behave. + /// A token used to cancel the operation. + /// A that completes when the connection has been established. + /// is . + /// + /// The API versions that the SDK supports don't overlap with the API versions that the daemon supports. + /// + /// An internal error occurred within the daemon. + /// + /// The request failed due to an underlying issue such as network connectivity, DNS failure, server certificate + /// validation, or timeout. + /// + public static async Task StartAsync(ClientOptions options, CancellationToken ct = default) + { + if (options is null) + throw new ArgumentNullException(nameof(options)); + if (options.DaemonUri is null) + throw new ArgumentException("The daemon URL is required.", nameof(options)); + + // First, establish a connection with the daemon. + Core.DockerClient core = options.ToCore().CreateClient(); + + // Now figure out which API version to use. This will be the max API version that both sides support. + CoreModels.VersionResponse versionInfo = await core.System.GetVersionAsync(ct).ConfigureAwait(false); + var negotiatedApiVersion = DetermineVersionToUse(_libraryMinApiVersion, versionInfo, _libraryMaxApiVersion); + + // Replace the non-versioned core instance with a versioned instance. + core.Dispose(); + core = options.ToCore().CreateClient(negotiatedApiVersion); + + // Now remove the reference to the credentials so they can drop out of memory as soon as possible. + options.Credentials = null; + + return new DockerClient(core, options, negotiatedApiVersion); + } + + /// + /// Determines which API version to use for communications between the SDK and the Docker daemon, or throws an + /// exception if there's no acceptable answer. + /// + /// The lowest API version that the SDK supports. + /// Information about what versions the daemon supports. + /// The highest API version that the SDK supports. + /// The API version to use. + /// + /// There's no overlap between what the two sides can accept. + /// + internal static Version DetermineVersionToUse(Version libraryMin, CoreModels.VersionResponse daemonInfo, Version libraryMax) + { + // Watch out for daemons that are so old that they don't even report APIVersion. + if (string.IsNullOrEmpty(daemonInfo.APIVersion)) + throw new DockerVersionException($"The daemon did not report its supported API version, which likely means that it is extremely old. The SDK only supports API versions down to v{libraryMin.ToString(2)}."); + + // Check that we support the daemon's max API version. + var daemonMaxVersion = new Version(daemonInfo.APIVersion); + if (daemonMaxVersion < libraryMin) + throw new DockerVersionException($"Version mismatch: The Docker daemon only supports API versions up to v{daemonMaxVersion.ToString(2)}, and the Docker SDK library only supports API versions down to v{libraryMin.ToString(2)}."); + + // Check the daemon's min API version. + var daemonMinVersion = new Version(daemonInfo.MinAPIVersion); + if (daemonMinVersion > libraryMax) + throw new DockerVersionException($"Version mismatch: The Docker daemon supports API versions v{daemonMinVersion.ToString(2)} through v{daemonMaxVersion.ToString(2)}, and the Docker SDK library supports API versions v{libraryMin.ToString(2)} through v{libraryMax.ToString(2)}."); + + // Use the highest version that both sides support. + return daemonMaxVersion < libraryMax ? daemonMaxVersion : libraryMax; + } + + /// + /// Throws an exception if the negotiated API version is not in the expected range. + /// + /// The minimum allowed API version, in MAJOR.MINOR format. + /// + /// The maximum allowed API version, in MAJOR.MINOR format, or for no upper limit. + /// + /// The negotiated API version is not in the expected range. + /// This object has been disposed. + /// + internal void RequireApiVersion(string minVersion = null, string maxVersion = null) + { + if (_isDisposed) + throw new ObjectDisposedException("This object has been disposed."); + + var version = ApiVersion; + if (minVersion != null && version < new Version(minVersion)) + throw new NotSupportedException($"This feature is not available until API version v{minVersion}. You are currently using API version v{version.ToString(2)}."); + if (maxVersion != null && version > new Version(maxVersion)) + throw new NotSupportedException($"This feature has not been available since API version v{maxVersion}. You are currently using API version v{version.ToString(2)}."); + } + + #region IDisposable + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + { + Core?.Dispose(); + } + + _isDisposed = true; + } + } + + #endregion IDisposable + } +} diff --git a/src/DockerSdk/DockerException.cs b/src/DockerSdk/DockerException.cs new file mode 100644 index 000000000..930e73711 --- /dev/null +++ b/src/DockerSdk/DockerException.cs @@ -0,0 +1,50 @@ +using System; +using System.Net; +using Api = Docker.DotNet; + +namespace DockerSdk +{ + /// + /// Base class for exceptions that are specific to the Docker client's functionality. + /// + [Serializable] + public class DockerException : Exception + { + public DockerException() + { + } + + public DockerException(string message) : base(message) + { + } + + public DockerException(string message, Exception inner) + : base(message + " See inner exception for details.", inner) + { + } + + protected DockerException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + /// + /// Creates a friendlier exception from an exception generated by the core API. + /// + /// The exception from the core API. + /// The wrapper exception. + internal static DockerException Wrap(Api.DockerApiException ex) + { + switch (ex.StatusCode) + { + case HttpStatusCode.BadRequest: + return new DockerException("The Docker daemon rejected the request because a parameter is invalid.", ex); + + case HttpStatusCode.InternalServerError: + return new DockerException("The Docker daemon reported an internal error.", ex); + + default: + return new DockerException($"The Docker daemon responded with unexpected status code {(int)ex.StatusCode}.", ex); + } + } + } +} diff --git a/src/DockerSdk/DockerSdk.csproj b/src/DockerSdk/DockerSdk.csproj new file mode 100644 index 000000000..2df15f5a1 --- /dev/null +++ b/src/DockerSdk/DockerSdk.csproj @@ -0,0 +1,21 @@ + + + + DockerSdk + DockerSdk is a library that allows you to interact with the Docker Remote API programmatically with fully asynchronous, non-blocking, and object-oriented code in your .NET applications. + DockerSdk + true + + + + + + + + + + + + + + diff --git a/src/DockerSdk/DockerVersionException.cs b/src/DockerSdk/DockerVersionException.cs new file mode 100644 index 000000000..a6c435110 --- /dev/null +++ b/src/DockerSdk/DockerVersionException.cs @@ -0,0 +1,28 @@ +using System; + +namespace DockerSdk +{ + /// + /// Indicates that API version negotiation failed because there is no overlap between the versions that the SDK + /// supports with the versions the Docker daemon supports. + /// + [Serializable] + public class DockerVersionException : DockerException + { + public DockerVersionException() + { + } + + public DockerVersionException(string message) : base(message) + { + } + + public DockerVersionException(string message, Exception inner) : base(message, inner) + { + } + + protected DockerVersionException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + } +} diff --git a/src/DockerSdk/Properties/TestAccess.cs b/src/DockerSdk/Properties/TestAccess.cs new file mode 100644 index 000000000..16c4c6ad5 --- /dev/null +++ b/src/DockerSdk/Properties/TestAccess.cs @@ -0,0 +1,2 @@ +// Allow the test project to access this project's internals. +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("DockerSdk.Tests")] diff --git a/src/DockerSdk/Registries/RegistryAccess.cs b/src/DockerSdk/Registries/RegistryAccess.cs new file mode 100644 index 000000000..2682f9551 --- /dev/null +++ b/src/DockerSdk/Registries/RegistryAccess.cs @@ -0,0 +1,254 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; + +using Core = Docker.DotNet; +using CoreModels = Docker.DotNet.Models; + +namespace DockerSdk.Registries +{ + /// + /// Caches credentials for Docker registries and provides a means to check the credentials against the registry. + /// + public class RegistryAccess + { + internal RegistryAccess(DockerClient client) + { + _client = client; + AddBuiltInRegistries(); + } + + /// + /// Gets the Docker registries that have cache entries. + /// + public IEnumerable Registries => _entriesByServer.Keys; + + private readonly DockerClient _client; + + private readonly Dictionary _entriesByServer = new Dictionary(StringComparer.InvariantCultureIgnoreCase); + + /// + /// Specifies that you want to use anonymous access to the indicated registry. + /// + /// The name of the registry, as used in image names. + /// The input is null, empty, or malformatted. + public void AddAnonymous(string registry) + { + // Scrub the input. + ValidateRegistryName(registry); + + // Get or add an entry for the registry. + if (!_entriesByServer.TryGetValue(registry, out RegistryEntry entry)) + entry = _entriesByServer[registry] = new RegistryEntry(registry); + + entry.AuthObject = new CoreModels.AuthConfig { ServerAddress = registry }; + entry.IsAnonymous = true; + } + + /// + /// Specifies that you want to use basic + /// authentication for access to the indicated registry. + /// + /// The name of the registry, as used in image names. + /// The username to use for the registry. + /// The password to use for the registry. + /// + /// The registry input is null, empty, or malformatted; or the username or password are null or empty. + /// + public void AddBasicAuth(string registry, string username, string password) + { + // Scrub the inputs. + ValidateRegistryName(registry); + if (string.IsNullOrEmpty(username)) + throw new ArgumentException($"'{nameof(username)}' cannot be null or empty", nameof(username)); + if (string.IsNullOrEmpty(password)) + throw new ArgumentException($"'{nameof(password)}' cannot be null or empty", nameof(password)); + + // Get or add an entry for the registry. + if (!_entriesByServer.TryGetValue(registry, out RegistryEntry entry)) + entry = _entriesByServer[registry] = new RegistryEntry(registry); + + entry.AuthObject = new CoreModels.AuthConfig { ServerAddress = registry, Username = username, Password = password }; + entry.IsAnonymous = false; + } + + /// + /// Specifies that you want to use an identity token for authenticating with the indicated registry. + /// + /// The name of the registry, as used in image names. + /// An identity token granted by the registry. + /// + /// The registry input is null, empty, or malformatted; or the identity token is null or empty. + /// + public void AddIdentityToken(string registry, string identityToken) + { + // Scrub the inputs. + ValidateRegistryName(registry); + if (string.IsNullOrEmpty(identityToken)) + throw new ArgumentException($"'{nameof(identityToken)}' cannot be null or empty", nameof(identityToken)); + + // Get or add an entry for the registry. + if (!_entriesByServer.TryGetValue(registry, out RegistryEntry entry)) + entry = _entriesByServer[registry] = new RegistryEntry(registry); + + entry.AuthObject = new CoreModels.AuthConfig { ServerAddress = registry, IdentityToken = identityToken }; + entry.IsAnonymous = false; + } + + /// + /// Tests whether the client can authenticate with the indicated registry. + /// + /// + /// + /// + /// + /// Use the various Add* methods to supply authentication instructions. If no such instructions are + /// provided, this method will try anonymous access. + /// + /// The registry input is null, empty, or malformatted. + /// + /// The request failed due to an underlying issue such as network connectivity, DNS failure, server certificate + /// validation, or timeout. + /// + public async Task CheckAuthenticationAsync(string registry, CancellationToken ct = default) + { + // Scrub the inputs. + ValidateRegistryName(registry); + + // If we have an entry for that registry, get the auth object. Otherwise create a new auth object, and set a + // flag indicating that the registry isn't already known. + bool isInCache = TryGetAuthObject(registry, out CoreModels.AuthConfig authObject); + if (!isInCache) + authObject = new CoreModels.AuthConfig { ServerAddress = registry }; + + try + { + // Try to authenticate. + await _client.Core.System.AuthenticateAsync(authObject, ct).ConfigureAwait(false); + } + catch (Core.DockerApiException ex) + { + if (ex.StatusCode == HttpStatusCode.Unauthorized) + return false; + if (ex.StatusCode == HttpStatusCode.InternalServerError && ex.Message.Contains("401 Unauthorized")) + return false; + if (ex.StatusCode == HttpStatusCode.InternalServerError && ex.Message.Contains("no basic auth credentials")) + return false; // the registry only accepts basic auth and was given something else + + throw DockerException.Wrap(ex); + } + + // TODO after https://github.com/dotnet/Docker.DotNet/issues/493 is resolved: if we're given an auth token, + // clear username/pass and start using that instead + + // If it wasn't in cache but it accepted anonymous access, cache it now. + if (!isInCache) + AddAnonymous(registry); + + return true; + } + + /// + /// Removes all custom registry entries. + /// + public void Clear() + { + _entriesByServer.Clear(); + AddBuiltInRegistries(); + } + + /// + /// Parses an image's name to determine which registry the image is associated with. + /// + /// The name of image. (Not the ID.) + /// The registry hostname, possibly including a port. + public string GetRegistryName(string imageName) + { + if (string.IsNullOrEmpty(imageName)) + throw new ArgumentException("Must not be null or empty.", nameof(imageName)); + + // There's always at least one non-host component, so if there's only one component we know there's no host. + // In that case Dockerhub is the registry. + var components = imageName.Split('/'); + if (components.Length == 1) + return "docker.io"; + + // If there's a host component, it's always the first component. + var candidate = components[0]; + + // The only components that are allowed a : character are host components and final components. We're + // looking at a non-final component, so if we find a : we know that we have a registry. + if (candidate.Contains(':')) + return candidate; + + // Only host components are allowed to use uppercase letters, so that's the next most straightforward way to + // test. + if (candidate.Any(char.IsUpper)) + return candidate; + + // The remaining criteria are ambiguous, so we're basically making educated guesses. First, check for + // well-known references to the current host. Note that we don't need to check the IP v6 loopback address + // because it can't be part of a valid image name. + if (candidate == "localhost" || candidate == "127.0.0.1") + return candidate; + + // Otherwise, if we have a cached registry with this name, treat it as a registry. + if (_entriesByServer.ContainsKey(candidate)) + return candidate; + + // Otherwise, if it contains any . characters, assume that it's a hostname. + if (candidate.Contains('.')) + return candidate; + + // Otherwise, assume that it isn't a host name, in which case we use Dockerhub. + return "docker.io"; + } + + /// + /// Removes an entry from the cache. + /// + /// The host name of the registry to remove. + /// True if the entry was removed, or false if the entry was not present. + /// This method is equivalent to docker logout. + public bool Remove(string registry) => _entriesByServer.Remove(registry); + + /// + /// Gets the auth information for the given image's registry. + /// + /// The name of image. ( Not the ID.) + /// Auth details for communicating with the image's registry, if known. + internal bool TryGetAuthObject(string registry, out CoreModels.AuthConfig authObject) + { + if (!_entriesByServer.TryGetValue(registry, out RegistryEntry entry)) + { + authObject = null; + return false; + } + + authObject = entry.AuthObject; + return true; + } + + private static void ValidateRegistryName(string registry) + { + if (string.IsNullOrEmpty(registry)) + throw new ArgumentException($"'{nameof(registry)}' cannot be null or empty", nameof(registry)); + if (registry.Contains("//")) + throw new ArgumentException("The registry name must not include the protocol.", nameof(registry)); + if (registry.Contains('/')) + throw new ArgumentException("The registry name must not include a path.", nameof(registry)); + } + + private void AddBuiltInRegistries() + { + // These common registries allow anonymous access for public images. + AddAnonymous("docker.io"); // Dockerhub + AddAnonymous("ghcr.io"); // Github + AddAnonymous("mcr.microsoft.com"); // Microsoft Azure + AddAnonymous("public.ecr.aws"); // Amazon AWS + } + } +} diff --git a/src/DockerSdk/Registries/RegistryEntry.cs b/src/DockerSdk/Registries/RegistryEntry.cs new file mode 100644 index 000000000..947b8d563 --- /dev/null +++ b/src/DockerSdk/Registries/RegistryEntry.cs @@ -0,0 +1,56 @@ +using System; +using System.Text; +using Newtonsoft.Json; +using CoreModels = Docker.DotNet.Models; + +namespace DockerSdk.Registries +{ + /// + /// Holds authentication information for a Docker registry. + /// + internal class RegistryEntry + { + public RegistryEntry(string server) + { + Server = server; + } + + /// + /// Gets or sets the auth object that the core API uses for authentication. + /// + public CoreModels.AuthConfig AuthObject + { + get => Decode(encodedAuthData); + set => encodedAuthData = Encode(value); + } + + public bool IsAnonymous { get; set; } + + public string Server { get; } + + private string encodedAuthData; + + /// + /// De-obfuscates an auth object. + /// + /// An obsfucated, serialized form of the given auth details. + /// An object holding the auth details. + private static CoreModels.AuthConfig Decode(string codedForm) + { + var json = Encoding.UTF8.GetString(Convert.FromBase64String(codedForm)); + return JsonConvert.DeserializeObject(json); + } + + /// + /// Obsfucates the auth object. This doesn't add much real security, but at least the passwords won't be plainly + /// visible in memory dumps. + /// + /// An object holding the auth details. + /// An obsfucated, serialized form of the given auth details. + private static string Encode(CoreModels.AuthConfig auth) + { + var json = JsonConvert.SerializeObject(auth); + return Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); + } + } +} diff --git a/test/DockerSdk.Tests/.editorconfig b/test/DockerSdk.Tests/.editorconfig new file mode 100644 index 000000000..b82d1b0e9 --- /dev/null +++ b/test/DockerSdk.Tests/.editorconfig @@ -0,0 +1,123 @@ +# To learn more about .editorconfig see https://aka.ms/editorconfigdocs + +############################### +# Core EditorConfig Options # +############################### + +# inherited + +############################### +# .NET Coding Conventions # +############################### + +[*.cs] + +# Organize usings +dotnet_sort_system_directives_first = true +# this. preferences +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_property = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_event = false:silent +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent +dotnet_style_readonly_field = true:suggestion +# Expression-level preferences +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent + +############################### +# Naming Conventions # +############################### + +# Style Definitions +dotnet_naming_style.pascal_case_style.capitalization = pascal_case +# Use PascalCase for constant fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.applicable_accessibilities = * +dotnet_naming_symbols.constant_fields.required_modifiers = const + +############################### +# C# Coding Conventions # +############################### + +[*.cs] + +# var preferences +csharp_style_var_for_built_in_types = true:silent +csharp_style_var_when_type_is_apparent = true:silent +csharp_style_var_elsewhere = true:silent +# Expression-bodied members +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent +# Pattern matching preferences +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +# Null-checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion +# Modifier preferences +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion +# Expression-level preferences +csharp_prefer_braces = true:silent +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion + +############################### +# C# Formatting Rules # +############################### + +# New line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true +# Indentation preferences +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left +# Space preferences +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_around_binary_operators = before_and_after +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +# Wrapping preferences +csharp_preserve_single_line_statements = true +csharp_preserve_single_line_blocks = true + diff --git a/test/DockerSdk.Tests/Cli.cs b/test/DockerSdk.Tests/Cli.cs new file mode 100644 index 000000000..3635999da --- /dev/null +++ b/test/DockerSdk.Tests/Cli.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; + +namespace DockerSdk.Tests +{ + /// + /// Runs commands on in a PowerShell command line. This is meant for setting up tests via the docker CLI. + /// + internal static class Cli + { + public static string ReadAndDeleteSingleLineFile(string path) + { + var line = File.ReadAllLines(path)[0].Trim(); + File.Delete(path); + return line; + } + + /// + /// Runs a series of commands in a Powershell prompt. + /// + /// An array where each line is a command to run. + /// An array of lines written to stdout across all commands. + /// + /// If any command returns a non-zero exit code or writes to stderr, the subsequent commands do not run.
+ /// Use of stdin is not supported. + ///
+ /// + /// One of the commands either wrote to stdout or returned a non-zero exit code. + /// + public static string[] Run(params string[] commands) + => Run(false, commands); + + /// + /// Runs a series of commands in a Powershell prompt. + /// + /// True to ignore any errors that the commands may raise. + /// An array where each line is a command to run. + /// An array of lines written to stdout across all commands. + /// + /// If any command returns a non-zero exit code or writes to stderr, the subsequent commands do not run, unless + /// is true.
Use of stdin is not supported. + ///
+ /// + /// One of the commands either wrote to stdout or returned a non-zero exit code, and was false. + /// + public static string[] Run(bool ignoreErrors, params string[] commands) + { + var output = new List(); + foreach (var command in commands) + output.AddRange(Run(command, ignoreErrors)); + return output.ToArray(); + } + + /// + /// Runs a command in a Powershell prompt. + /// + /// The command to run. + /// An array of lines written to stdout. + /// + /// The command either wrote to stdout or returned a non-zero exit code. + /// + public static string[] Run(string command) + => Run(command, false); + + /// + /// Runs a command in a Powershell prompt. + /// + /// The command to run. + /// True to ignore any errors that the command may raise. + /// An array of lines written to stdout. + /// + /// The command either wrote to stdout or returned a non-zero exit code, and is + /// false. + /// + public static string[] Run(string command, bool ignoreErrors) + { + var pi = new ProcessStartInfo("pwsh.exe", "-Command -") + { + CreateNoWindow = true, + LoadUserProfile = false, + UseShellExecute = false, // needed for .Net Framework, which defaults it to true + RedirectStandardInput = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + }; + using var process = Process.Start(pi); + + process.StandardInput.WriteLine(command); + process.StandardInput.WriteLine("exit"); + process.WaitForExit(); + + if (!ignoreErrors) + { + var errors = process.StandardError.ReadToEnd(); + if (!string.IsNullOrEmpty(errors)) + throw new InvalidOperationException(errors); + + if (process.ExitCode != 0) + throw new InvalidOperationException($"The process exited with code {process.ExitCode}."); + } + + return process.StandardOutput.ReadToEnd() + .Split('\n') + .Select(line => line.Trim()) + .ToArray(); + } + } +} diff --git a/test/DockerSdk.Tests/ClientTests.cs b/test/DockerSdk.Tests/ClientTests.cs new file mode 100644 index 000000000..2c0069347 --- /dev/null +++ b/test/DockerSdk.Tests/ClientTests.cs @@ -0,0 +1,34 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; + +namespace DockerSdk.Tests +{ + [Collection("Common")] + public class ClientTests + { + [Fact] + public async Task ConnectAsync_Defaults_HasCorrectApiVersion() + { + using var client = await DockerClient.StartAsync(); + client.ApiVersion.ToString().Should().Be("1.41"); + } + + [Fact] + public async Task ConnectAsync_Defaults_ReturnsClientObject() + { + using var docker = await DockerClient.StartAsync(); + + docker.Should().NotBeNull(); + } + + [Fact] + public async Task ConnectAsync_NoSuchPlace_Throws() + { + await Assert.ThrowsAsync( + () => DockerClient.StartAsync(new Uri("http://localhost:123"))); + } + } +} diff --git a/test/DockerSdk.Tests/ClientUnitTests.cs b/test/DockerSdk.Tests/ClientUnitTests.cs new file mode 100644 index 000000000..4553e895f --- /dev/null +++ b/test/DockerSdk.Tests/ClientUnitTests.cs @@ -0,0 +1,125 @@ +using System; +using FluentAssertions; +using Xunit; +using CoreModels = Docker.DotNet.Models; + +namespace DockerSdk.Tests +{ + // These tests can be done in parallel with other tests. + public class ClientUnitTests + { + [Fact] + public void DetermineVersionToUse_DaemonRangeEngulfsSdkRange_ReturnsSdkMax() + { + var input = new CoreModels.VersionResponse + { + MinAPIVersion = "1.11", + APIVersion = "1.91", + }; + var libMin = new Version("1.31"); + var libMax = new Version("1.71"); + + var result = DockerClient.DetermineVersionToUse(libMin, input, libMax); + + result.ToString().Should().Be("1.71"); + } + + [Fact] + public void DetermineVersionToUse_ExactlyOneVersionEach_SameVersion_ReturnsThatVersion() + { + var input = new CoreModels.VersionResponse + { + MinAPIVersion = "1.23", + APIVersion = "1.23", + }; + var libMin = new Version("1.23"); + var libMax = new Version("1.23"); + + var result = DockerClient.DetermineVersionToUse(libMin, input, libMax); + + result.ToString().Should().Be("1.23"); + } + + [Fact] + public void DetermineVersionToUse_NoOverlap_DaemonHigher_Throws() + { + var input = new CoreModels.VersionResponse + { + MinAPIVersion = "1.71", + APIVersion = "1.91", + }; + var libMin = new Version("1.11"); + var libMax = new Version("1.31"); + + var ex = Assert.Throws( + () => DockerClient.DetermineVersionToUse(libMin, input, libMax)); + + ex.Message.Should().Be("Version mismatch: The Docker daemon supports API versions v1.71 through v1.91, and the Docker SDK library supports API versions v1.11 through v1.31."); + } + + [Fact] + public void DetermineVersionToUse_NoOverlap_SdkHigher_Throws() + { + var input = new CoreModels.VersionResponse + { + MinAPIVersion = "1.11", + APIVersion = "1.31", + }; + var libMin = new Version("1.71"); + var libMax = new Version("1.91"); + + var ex = Assert.Throws( + () => DockerClient.DetermineVersionToUse(libMin, input, libMax)); + + ex.Message.Should().Be("Version mismatch: The Docker daemon only supports API versions up to v1.31, and the Docker SDK library only supports API versions down to v1.71."); + } + + [Fact] + public void DetermineVersionToUse_Overlap_DaemonHigher_ReturnsSdkMax() + { + var input = new CoreModels.VersionResponse + { + MinAPIVersion = "1.31", + APIVersion = "1.91", + }; + var libMin = new Version("1.11"); + var libMax = new Version("1.71"); + + var result = DockerClient.DetermineVersionToUse(libMin, input, libMax); + + result.ToString().Should().Be("1.71"); + } + + [Fact] + public void DetermineVersionToUse_Overlap_SdkHigher_ReturnsDaemonMax() + { + var input = new CoreModels.VersionResponse + { + MinAPIVersion = "1.11", + APIVersion = "1.71", + }; + var libMin = new Version("1.31"); + var libMax = new Version("1.91"); + + var result = DockerClient.DetermineVersionToUse(libMin, input, libMax); + + result.ToString().Should().Be("1.71"); + } + + [Fact] + public void DetermineVersionToUse_SdkRangeEngulfsDaemonRange_ReturnsDaemonMax() + { + var input = new CoreModels.VersionResponse + { + MinAPIVersion = "1.31", + APIVersion = "1.71", + }; + var libMin = new Version("1.11"); + var libMax = new Version("1.91"); + + var result = DockerClient.DetermineVersionToUse(libMin, input, libMax); + + result.ToString().Should().Be("1.71"); + } + } +} diff --git a/test/DockerSdk.Tests/DockerSdk.Tests.csproj b/test/DockerSdk.Tests/DockerSdk.Tests.csproj new file mode 100644 index 000000000..17e3ced7b --- /dev/null +++ b/test/DockerSdk.Tests/DockerSdk.Tests.csproj @@ -0,0 +1,39 @@ + + + + netcoreapp3.1 + enable + false + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + PreserveNewest + + + + diff --git a/test/DockerSdk.Tests/Fixture.cs b/test/DockerSdk.Tests/Fixture.cs new file mode 100644 index 000000000..f745f0bdb --- /dev/null +++ b/test/DockerSdk.Tests/Fixture.cs @@ -0,0 +1,26 @@ +using System; +using Xunit; + +namespace DockerSdk.Tests +{ + public sealed class Fixture : IDisposable + { + public Fixture() + { + // Start up the test environment. + Cli.Run("cd scripts && docker-compose up --build --detach --no-color", ignoreErrors: true); + } + + public void Dispose() + { + // Shut down the test environment. + Cli.Run("cd scripts && docker-compose down", ignoreErrors: true); + } + } + + [CollectionDefinition("Common")] + public class FixtureCollection : ICollectionFixture + { + // This class is never instantiated. It's simply a marker class used by XUnit. + } +} diff --git a/test/DockerSdk.Tests/RegistryAccessTests.cs b/test/DockerSdk.Tests/RegistryAccessTests.cs new file mode 100644 index 000000000..8088aacae --- /dev/null +++ b/test/DockerSdk.Tests/RegistryAccessTests.cs @@ -0,0 +1,110 @@ +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; + +namespace DockerSdk.Tests +{ + [Collection("Common")] + public class RegistryAccessTests + { + private const string BasicAuthRegistry = "localhost:5001"; + private const string OpenRegistry = "localhost:4000"; + + [Fact] + public async Task CheckAuthenticationAsync_AnonymousAccess_ToBasicAuthRegistry_Fails() + { + using var client = await DockerClient.StartAsync(); + client.Registries.AddAnonymous(BasicAuthRegistry); + + var result = await client.Registries.CheckAuthenticationAsync(BasicAuthRegistry); + + result.Should().BeFalse(); + } + + [Fact] + public async Task CheckAuthenticationAsync_AnonymousAccess_ToDockerhub_Suceeds() + { + using var client = await DockerClient.StartAsync(); + + var result = await client.Registries.CheckAuthenticationAsync("docker.io"); + + result.Should().BeTrue(); + } + + [Fact] + public async Task CheckAuthenticationAsync_AnonymousAccess_ToRecognizedOpenRegistry_Suceeds() + { + using var client = await DockerClient.StartAsync(); + client.Registries.AddAnonymous(OpenRegistry); + + var result = await client.Registries.CheckAuthenticationAsync(OpenRegistry); + + result.Should().BeTrue(); + } + + [Fact] + public async Task CheckAuthenticationAsync_AnonymousAccess_ToUnrecognizedOpenRegistry_Suceeds() + { + using var client = await DockerClient.StartAsync(); + + var result = await client.Registries.CheckAuthenticationAsync(OpenRegistry); + + result.Should().BeTrue(); + } + + [Fact] + public async Task CheckAuthenticationAsync_BasicAuth_ToOpenRegistry_Succeeds() + { + using var client = await DockerClient.StartAsync(); + client.Registries.AddBasicAuth(OpenRegistry, "donald", "duck"); + + var result = await client.Registries.CheckAuthenticationAsync(OpenRegistry); + + result.Should().BeTrue(); + } + + [Fact] + public async Task CheckAuthenticationAsync_CorrectCredentials_ToBasicAuthRegistry_Fails() + { + using var client = await DockerClient.StartAsync(); + client.Registries.AddBasicAuth(BasicAuthRegistry, "testuser", "testpassword"); + + var result = await client.Registries.CheckAuthenticationAsync(BasicAuthRegistry); + + result.Should().BeTrue(); + } + + [Fact] + public async Task CheckAuthenticationAsync_IncorrectCredentials_ToBasicAuthRegistry_Fails() + { + using var client = await DockerClient.StartAsync(); + client.Registries.AddBasicAuth(BasicAuthRegistry, "wronguser", "wrongpassword"); + + var result = await client.Registries.CheckAuthenticationAsync(BasicAuthRegistry); + + result.Should().BeFalse(); + } + + [Fact] + public async Task CheckAuthenticationAsync_MadeUpAuthToken_ToBasicAuthRegistry_Fails() + { + using var client = await DockerClient.StartAsync(); + client.Registries.AddIdentityToken(BasicAuthRegistry, "faketoken"); + + var result = await client.Registries.CheckAuthenticationAsync("localhost:5001"); + + result.Should().BeFalse(); + } + + [Fact] + public async Task CheckAuthenticationAsync_MadeUpAuthToken_ToOpenRegistry_Succeeds() + { + using var client = await DockerClient.StartAsync(); + client.Registries.AddIdentityToken(OpenRegistry, "faketoken"); + + var result = await client.Registries.CheckAuthenticationAsync(OpenRegistry); + + result.Should().BeTrue(); + } + } +} diff --git a/test/DockerSdk.Tests/RegistryAccessUnitTests.cs b/test/DockerSdk.Tests/RegistryAccessUnitTests.cs new file mode 100644 index 000000000..ff8da1983 --- /dev/null +++ b/test/DockerSdk.Tests/RegistryAccessUnitTests.cs @@ -0,0 +1,104 @@ +using DockerSdk.Registries; +using FluentAssertions; +using Xunit; + +using CoreModels = Docker.DotNet.Models; + +namespace DockerSdk.Tests +{ + // These tests can run in parallel with other tests. + public class RegistryAccessUnitTests + { + [Fact] + public void AddAnonymous_AddsEmptyAuthObject() + { + var access = new RegistryAccess(null); + + access.AddAnonymous("abc"); + + access.TryGetAuthObject("abc", out CoreModels.AuthConfig actual).Should().BeTrue(); ; + actual.Should().BeEquivalentTo(new CoreModels.AuthConfig { ServerAddress = "abc" }); + } + + [Fact] + public void AddBasicAuth_AddsUserPasswordAuthObject() + { + var access = new RegistryAccess(null); + + access.AddBasicAuth("abc", "donald", "duck"); + + access.TryGetAuthObject("abc", out CoreModels.AuthConfig actual).Should().BeTrue(); ; + actual.Should().BeEquivalentTo(new CoreModels.AuthConfig { ServerAddress = "abc", Username = "donald", Password = "duck" }); + } + + [Fact] + public void AddBasicAuth_ToAnonymousRegistry_ConvertsEntryToBasicAuth() + { + var access = new RegistryAccess(null); + + access.AddBasicAuth("docker.io", "donald", "duck"); + + access.TryGetAuthObject("docker.io", out CoreModels.AuthConfig actual).Should().BeTrue(); ; + actual.Should().BeEquivalentTo(new CoreModels.AuthConfig { ServerAddress = "docker.io", Username = "donald", Password = "duck" }); + } + + [Fact] + public void AddIdentityToken_AddsIdentityTokenAuthObject() + { + var access = new RegistryAccess(null); + + access.AddIdentityToken("abc", "123-456-789"); + + access.TryGetAuthObject("abc", out CoreModels.AuthConfig actual).Should().BeTrue(); ; + actual.Should().BeEquivalentTo(new CoreModels.AuthConfig { ServerAddress = "abc", IdentityToken = "123-456-789" }); + } + + [Fact] + public void Clear_PreservesBuiltIns() + { + var access = new RegistryAccess(null); + + access.Clear(); + + access.Registries.Should().Contain("docker.io"); + } + + [Theory] + [InlineData("test", "docker.io")] + [InlineData("example.com/test", "example.com")] + [InlineData("example:123/test", "example:123")] + [InlineData("example/test:123", "docker.io")] + [InlineData("example.com", "docker.io")] + [InlineData("example.com:123", "docker.io")] + [InlineData("Example/test", "Example")] + [InlineData("localhost/test", "localhost")] + public void GetRegistryName_ProducesExpectedOutput(string imageName, string expected) + { + var access = new RegistryAccess(null); + + var actual = access.GetRegistryName(imageName); + + actual.Should().Be(expected); + } + + [Fact] + public void Remove_RemovesEntry() + { + var access = new RegistryAccess(null); + + access.Remove("docker.io"); + + access.TryGetAuthObject("docker.io", out _).Should().BeFalse(); + } + + [Fact] + public void TryGetAuthObject_UnknownRegistry_ReturnsFalse() + { + var access = new RegistryAccess(null); + + var result = access.TryGetAuthObject("example.com", out _); + + result.Should().BeFalse(); + } + } +} diff --git a/test/DockerSdk.Tests/scripts/docker-compose.yml b/test/DockerSdk.Tests/scripts/docker-compose.yml new file mode 100644 index 000000000..49b9ebd0e --- /dev/null +++ b/test/DockerSdk.Tests/scripts/docker-compose.yml @@ -0,0 +1,18 @@ +# Sets up the docker containers needed by the project's tests. +version: '3.9' + +services: + + registry-open: + build: registry-open + image: ddnt:registry-open + container_name: ddnt-registry-open + ports: + - "4000:80" + + registry-htpasswd-tls: + build: registry-htpasswd-tls + image: ddnt:registry-htpasswd-tls + container_name: ddnt-registry-htpasswd-tls + ports: + - "5001:443" diff --git a/test/DockerSdk.Tests/scripts/registry-htpasswd-tls/Dockerfile b/test/DockerSdk.Tests/scripts/registry-htpasswd-tls/Dockerfile new file mode 100644 index 000000000..46d308535 --- /dev/null +++ b/test/DockerSdk.Tests/scripts/registry-htpasswd-tls/Dockerfile @@ -0,0 +1,22 @@ +# Creates an image that hosts a Docker registry. The registry uses basic +# authentication with TLS. The username is "testuser" and the password is +# "testpassword". + +FROM alpine AS build +RUN apk update +RUN apk add openssl +RUN openssl req -newkey rsa:2048 -nodes -keyout sdk-test.key -x509 -days 3650 -out sdk-test.crt -subj "/C=US/ST=Wisconsin/L=Madison/O=Emdot/CN=docker-net-sdk-tests" +RUN apk add apache2-utils +RUN htpasswd -Bbn testuser testpassword > htpasswd + +FROM registry:2 +RUN mkdir /certs +COPY --from=build sdk-test.* /certs/ +RUN mkdir /auth +COPY --from=build htpasswd /auth/ +ENV REGISTRY_HTTP_ADDR=0.0.0.0:443 +ENV REGISTRY_HTTP_TLS_CERTIFICATE=/certs/sdk-test.crt +ENV REGISTRY_HTTP_TLS_KEY=/certs/sdk-test.key +ENV REGISTRY_AUTH=htpasswd +ENV REGISTRY_AUTH_HTPASSWD_REALM="Registry Realm" +ENV REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd diff --git a/test/DockerSdk.Tests/scripts/registry-open/Dockerfile b/test/DockerSdk.Tests/scripts/registry-open/Dockerfile new file mode 100644 index 000000000..58d4034ed --- /dev/null +++ b/test/DockerSdk.Tests/scripts/registry-open/Dockerfile @@ -0,0 +1,3 @@ +# Creates an image that hosts a Docker registry. It accepts all authentication attempts. +FROM registry:2 +ENV REGISTRY_HTTP_ADDR=0.0.0.0:80 \ No newline at end of file From b34e3502defac16cc96bf65fb92c4f40abfd67c3 Mon Sep 17 00:00:00 2001 From: Matt Morrison <3241034+Emdot@users.noreply.github.com> Date: Mon, 22 Feb 2021 18:29:31 -0600 Subject: [PATCH 2/3] Don''t set LoadUserProfile, because it's Windows-only. --- test/DockerSdk.Tests/Cli.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/DockerSdk.Tests/Cli.cs b/test/DockerSdk.Tests/Cli.cs index 3635999da..5fbe1c89d 100644 --- a/test/DockerSdk.Tests/Cli.cs +++ b/test/DockerSdk.Tests/Cli.cs @@ -81,7 +81,6 @@ public static string[] Run(string command, bool ignoreErrors) var pi = new ProcessStartInfo("pwsh.exe", "-Command -") { CreateNoWindow = true, - LoadUserProfile = false, UseShellExecute = false, // needed for .Net Framework, which defaults it to true RedirectStandardInput = true, RedirectStandardError = true, From da2e268b99dba7a2c3db7ea8b3dfa60826e73ede Mon Sep 17 00:00:00 2001 From: Matt Morrison <3241034+Emdot@users.noreply.github.com> Date: Tue, 23 Feb 2021 18:59:54 -0600 Subject: [PATCH 3/3] Fixed CLI.cs to work on non-Windows OSes. --- test/DockerSdk.Tests/Cli.cs | 58 +++++++++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/test/DockerSdk.Tests/Cli.cs b/test/DockerSdk.Tests/Cli.cs index 5fbe1c89d..a26986638 100644 --- a/test/DockerSdk.Tests/Cli.cs +++ b/test/DockerSdk.Tests/Cli.cs @@ -78,7 +78,15 @@ public static string[] Run(string command) /// public static string[] Run(string command, bool ignoreErrors) { - var pi = new ProcessStartInfo("pwsh.exe", "-Command -") + // Get the name of the PowerShell executable, which depends on the platform. + string powershellCommand + = Environment.OSVersion.Platform == PlatformID.Win32NT + ? "pwsh.exe" + : "pwsh"; + + // Declare how to start the process. The trailing - in the parameters means + // to read the command from stdin. + var pi = new ProcessStartInfo(powershellCommand, "-Command -") { CreateNoWindow = true, UseShellExecute = false, // needed for .Net Framework, which defaults it to true @@ -86,26 +94,44 @@ public static string[] Run(string command, bool ignoreErrors) RedirectStandardError = true, RedirectStandardOutput = true, }; - using var process = Process.Start(pi); - process.StandardInput.WriteLine(command); - process.StandardInput.WriteLine("exit"); - process.WaitForExit(); + // Start the process. + Process process; + try + { + process = Process.Start(pi); + } + catch (System.ComponentModel.Win32Exception ex) + { + throw new InvalidOperationException($"Failed to start PowerShell. Is it installed and in the PATH? (Command: {pi.FileName} {pi.Arguments})", ex); + } - if (!ignoreErrors) + using (process) { - var errors = process.StandardError.ReadToEnd(); - if (!string.IsNullOrEmpty(errors)) - throw new InvalidOperationException(errors); + // Feed the command to PowerShell via stdin. + process.StandardInput.WriteLine(command); - if (process.ExitCode != 0) - throw new InvalidOperationException($"The process exited with code {process.ExitCode}."); - } + // Shut down the process. + process.StandardInput.WriteLine("exit"); + process.WaitForExit(); + + // Throw an exception if errors were detected. (Unless supressed.) + if (!ignoreErrors) + { + var errors = process.StandardError.ReadToEnd(); + if (!string.IsNullOrEmpty(errors)) + throw new InvalidOperationException(errors); - return process.StandardOutput.ReadToEnd() - .Split('\n') - .Select(line => line.Trim()) - .ToArray(); + if (process.ExitCode != 0) + throw new InvalidOperationException($"The process exited with code {process.ExitCode}."); + } + + // Return the text that the process wrote to stdout. + return process.StandardOutput.ReadToEnd() + .Split('\n') + .Select(line => line.Trim()) + .ToArray(); + } } } }