Files
wehub-resource-sync e0e362d700
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
SDK Tests / SDK CI (push) Has been cancelled
SDK Tests / CLI Tests (push) Has been cancelled
SDK Tests / Python SDK Quality (code-interpreter) (push) Has been cancelled
SDK Tests / Python SDK Quality (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (sandbox) (push) Has been cancelled
SDK Tests / CLI Quality (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Go SDK Quality And Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:39:33 +08:00

524 lines
20 KiB
C#

// Copyright 2026 Alibaba Group Holding Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
using FluentAssertions;
using OpenSandbox.Config;
using OpenSandbox.Core;
using OpenSandbox.Factory;
using OpenSandbox.Models;
using OpenSandbox.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace OpenSandbox.Tests;
public class SandboxEgressLifecycleTests
{
[Fact]
public async Task CreateAsync_ShouldBuildEgressStackOnce_AndReuseItForOperations()
{
var sandboxes = new StubSandboxes();
var egress = new StubEgress();
var credentialVault = new StubCredentialVault();
var adapterFactory = new StubAdapterFactory(sandboxes, egress, credentialVault);
var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions
{
Image = "python:3.12",
ConnectionConfig = new ConnectionConfig(new ConnectionConfigOptions
{
Domain = "127.0.0.1:8080",
Protocol = ConnectionProtocol.Http
}),
AdapterFactory = adapterFactory,
SkipHealthCheck = true,
Diagnostics = new SdkDiagnosticsOptions
{
LoggerFactory = NullLoggerFactory.Instance
}
});
await sandbox.GetEgressPolicyAsync();
await sandbox.PatchEgressRulesAsync([new NetworkRule
{
Action = NetworkRuleAction.Allow,
Target = "www.github.com"
}]);
await sandbox.DeleteEgressRulesAsync(["www.github.com", "*.blocked.org"]);
await sandbox.CredentialVault.GetAsync();
await sandbox.GetCredentialVaultAsync();
sandboxes.EndpointCalls.Should().Equal(Constants.DefaultExecdPort, Constants.DefaultEgressPort);
adapterFactory.EgressStackCallCount.Should().Be(1);
adapterFactory.LastEgressBaseUrl.Should().Be($"http://127.0.0.1:{Constants.DefaultEgressPort}");
egress.GetPolicyCallCount.Should().Be(1);
egress.PatchRulesCallCount.Should().Be(1);
egress.DeleteRulesCallCount.Should().Be(1);
credentialVault.GetVaultCallCount.Should().Be(2);
egress.LastDeleteTargets.Should().Equal("www.github.com", "*.blocked.org");
}
[Fact]
public async Task CreateAsync_ShouldAcceptCustomEgressWithoutCredentialVaultMethods()
{
var sandboxes = new StubSandboxes();
var egress = new StubEgress();
var adapterFactory = new StubAdapterFactory(sandboxes, egress);
var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions
{
Image = "python:3.12",
ConnectionConfig = new ConnectionConfig(new ConnectionConfigOptions
{
Domain = "127.0.0.1:8080",
Protocol = ConnectionProtocol.Http
}),
AdapterFactory = adapterFactory,
SkipHealthCheck = true,
Diagnostics = new SdkDiagnosticsOptions
{
LoggerFactory = NullLoggerFactory.Instance
}
});
await sandbox.GetEgressPolicyAsync();
Func<Task> act = () => sandbox.CredentialVault.GetAsync();
egress.GetPolicyCallCount.Should().Be(1);
await act.Should().ThrowAsync<InvalidArgumentException>()
.WithMessage("Credential Vault is not available for this adapter factory*");
}
[Fact]
public async Task CreateAsync_ShouldAcceptWindowsHostPath()
{
var sandboxes = new StubSandboxes();
var adapterFactory = new StubAdapterFactory(sandboxes, new StubEgress());
await using var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions
{
Image = "python:3.12",
ConnectionConfig = new ConnectionConfig(new ConnectionConfigOptions
{
Domain = "127.0.0.1:8080",
Protocol = ConnectionProtocol.Http
}),
AdapterFactory = adapterFactory,
SkipHealthCheck = true,
CredentialProxy = new CredentialProxyConfig
{
Enabled = true
},
Volumes =
[
new Volume
{
Name = "host-vol",
Host = new Host { Path = "D:/sandbox-mnt/ReMe" },
MountPath = "/mnt/data"
}
],
Diagnostics = new SdkDiagnosticsOptions
{
LoggerFactory = NullLoggerFactory.Instance
}
});
sandboxes.LastCreateRequest.Should().NotBeNull();
sandboxes.LastCreateRequest!.CredentialProxy.Should().NotBeNull();
sandboxes.LastCreateRequest!.CredentialProxy!.Enabled.Should().BeTrue();
sandboxes.LastCreateRequest!.Volumes.Should().NotBeNull();
sandboxes.LastCreateRequest.Volumes!.Should().ContainSingle();
sandboxes.LastCreateRequest.Volumes![0].Host!.Path.Should().Be("D:/sandbox-mnt/ReMe");
}
[Fact]
public async Task CreateAsync_ShouldSupportSnapshotRestore()
{
var sandboxes = new StubSandboxes();
var adapterFactory = new StubAdapterFactory(sandboxes, new StubEgress());
await using var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions
{
SnapshotId = "snap-123",
ConnectionConfig = new ConnectionConfig(new ConnectionConfigOptions
{
Domain = "127.0.0.1:8080",
Protocol = ConnectionProtocol.Http
}),
AdapterFactory = adapterFactory,
SkipHealthCheck = true,
Diagnostics = new SdkDiagnosticsOptions
{
LoggerFactory = NullLoggerFactory.Instance
}
});
sandboxes.LastCreateRequest.Should().NotBeNull();
sandboxes.LastCreateRequest!.SnapshotId.Should().Be("snap-123");
sandboxes.LastCreateRequest.Image.Should().BeNull();
sandboxes.LastCreateRequest.Entrypoint.Should().BeNull();
}
[Fact]
public async Task CreateAsync_ShouldRejectRelativeHostPath()
{
var sandboxes = new StubSandboxes();
var adapterFactory = new StubAdapterFactory(sandboxes, new StubEgress());
Func<Task> act = async () =>
{
await Sandbox.CreateAsync(new SandboxCreateOptions
{
Image = "python:3.12",
ConnectionConfig = new ConnectionConfig(new ConnectionConfigOptions
{
Domain = "127.0.0.1:8080",
Protocol = ConnectionProtocol.Http
}),
AdapterFactory = adapterFactory,
SkipHealthCheck = true,
Volumes =
[
new Volume
{
Name = "host-vol",
Host = new Host { Path = "relative/path" },
MountPath = "/mnt/data"
}
],
Diagnostics = new SdkDiagnosticsOptions
{
LoggerFactory = NullLoggerFactory.Instance
}
});
};
await act.Should().ThrowAsync<InvalidArgumentException>()
.WithMessage("Host path must be an absolute path starting with '/' or a Windows drive letter*");
adapterFactory.LifecycleStackCallCount.Should().Be(0);
}
private sealed class StubAdapterFactory : IAdapterFactory
{
private readonly ISandboxes _sandboxes;
private readonly IEgress _egress;
private readonly ICredentialVault? _credentialVault;
public StubAdapterFactory(
ISandboxes sandboxes,
IEgress egress,
ICredentialVault? credentialVault = null)
{
_sandboxes = sandboxes;
_egress = egress;
_credentialVault = credentialVault;
}
public int EgressStackCallCount { get; private set; }
public int LifecycleStackCallCount { get; private set; }
public string? LastEgressBaseUrl { get; private set; }
public LifecycleStack CreateLifecycleStack(CreateLifecycleStackOptions options)
{
LifecycleStackCallCount++;
return new LifecycleStack
{
Sandboxes = _sandboxes
};
}
public ExecdStack CreateExecdStack(CreateExecdStackOptions options)
{
return new ExecdStack
{
Commands = new Mock<IExecdCommands>(MockBehavior.Strict).Object,
Files = new StubFiles(),
Health = new StubHealth(),
Metrics = new StubMetrics(),
Isolation = new Mock<IIsolatedSessions>(MockBehavior.Strict).Object
};
}
public EgressStack CreateEgressStack(CreateEgressStackOptions options)
{
EgressStackCallCount++;
LastEgressBaseUrl = options.EgressBaseUrl;
return new EgressStack
{
Egress = _egress,
CredentialVault = _credentialVault
};
}
}
private sealed class StubSandboxes : ISandboxes
{
public List<int> EndpointCalls { get; } = new();
public CreateSandboxRequest? LastCreateRequest { get; private set; }
public Task<CreateSandboxResponse> CreateSandboxAsync(CreateSandboxRequest request, CancellationToken cancellationToken = default)
{
LastCreateRequest = request;
return Task.FromResult(new CreateSandboxResponse
{
Id = "sandbox-test-id",
Status = new SandboxStatus
{
State = "Running"
},
CreatedAt = DateTime.UtcNow,
Entrypoint = ["/bin/sh"]
});
}
public Task<SandboxInfo> GetSandboxAsync(string sandboxId, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<ListSandboxesResponse> ListSandboxesAsync(ListSandboxesParams? @params = null, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<SandboxInfo> PatchSandboxMetadataAsync(string sandboxId, IReadOnlyDictionary<string, string?> patch, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task DeleteSandboxAsync(string sandboxId, CancellationToken cancellationToken = default) =>
Task.CompletedTask;
public Task PauseSandboxAsync(string sandboxId, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task ResumeSandboxAsync(string sandboxId, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<RenewSandboxExpirationResponse> RenewSandboxExpirationAsync(string sandboxId, RenewSandboxExpirationRequest request, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<SnapshotInfo> CreateSnapshotAsync(string sandboxId, CreateSnapshotRequest? request = null, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<SnapshotInfo> GetSnapshotAsync(string snapshotId, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<ListSnapshotsResponse> ListSnapshotsAsync(ListSnapshotsParams? @params = null, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task DeleteSnapshotAsync(string snapshotId, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<Endpoint> GetSandboxEndpointAsync(string sandboxId, int port, bool useServerProxy = false, CancellationToken cancellationToken = default)
{
EndpointCalls.Add(port);
return Task.FromResult(new Endpoint
{
EndpointAddress = $"127.0.0.1:{port}",
Headers = new Dictionary<string, string>
{
["X-Port"] = port.ToString()
}
});
}
public Task<Endpoint> GetSignedSandboxEndpointAsync(string sandboxId, int port, long expires, bool useServerProxy = false, CancellationToken cancellationToken = default)
{
EndpointCalls.Add(port);
return Task.FromResult(new Endpoint
{
EndpointAddress = $"127.0.0.1:{port}",
Headers = new Dictionary<string, string>
{
["X-Port"] = port.ToString()
}
});
}
}
private sealed class StubEgress : IEgress
{
public int GetPolicyCallCount { get; private set; }
public int PatchRulesCallCount { get; private set; }
public int DeleteRulesCallCount { get; private set; }
public IReadOnlyList<string> LastDeleteTargets { get; private set; } = [];
public Task<NetworkPolicy> GetPolicyAsync(CancellationToken cancellationToken = default)
{
GetPolicyCallCount++;
return Task.FromResult(new NetworkPolicy
{
DefaultAction = NetworkRuleAction.Deny,
Egress = [new NetworkRule
{
Action = NetworkRuleAction.Allow,
Target = "pypi.org"
}]
});
}
public Task PatchRulesAsync(IReadOnlyList<NetworkRule> rules, CancellationToken cancellationToken = default)
{
PatchRulesCallCount++;
return Task.CompletedTask;
}
public Task DeleteRulesAsync(IReadOnlyList<string> targets, CancellationToken cancellationToken = default)
{
DeleteRulesCallCount++;
LastDeleteTargets = targets.ToList();
return Task.CompletedTask;
}
}
private sealed class StubCredentialVault : ICredentialVault
{
public int GetVaultCallCount { get; private set; }
public Task<CredentialVaultState> CreateAsync(
IReadOnlyList<Credential> credentials,
IReadOnlyList<CredentialBinding> bindings,
CancellationToken cancellationToken = default)
{
return Task.FromResult(CreateVaultState());
}
public Task<CredentialVaultState> GetAsync(CancellationToken cancellationToken = default)
{
GetVaultCallCount++;
return Task.FromResult(CreateVaultState());
}
public Task<CredentialVaultState> PatchAsync(
CredentialVaultPatchRequest request,
CancellationToken cancellationToken = default)
{
return Task.FromResult(CreateVaultState());
}
public Task DeleteAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<IReadOnlyList<CredentialMetadata>> ListCredentialsAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult<IReadOnlyList<CredentialMetadata>>(CreateVaultState().Credentials);
}
public Task<CredentialMetadata> GetCredentialAsync(
string name,
CancellationToken cancellationToken = default)
{
return Task.FromResult(CreateVaultState().Credentials[0]);
}
public Task<IReadOnlyList<CredentialBindingMetadata>> ListBindingsAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult<IReadOnlyList<CredentialBindingMetadata>>(CreateVaultState().Bindings);
}
public Task<CredentialBindingMetadata> GetBindingAsync(
string name,
CancellationToken cancellationToken = default)
{
return Task.FromResult(CreateVaultState().Bindings[0]);
}
private static CredentialVaultState CreateVaultState()
{
return new CredentialVaultState
{
Revision = 1,
Credentials =
[
new CredentialMetadata
{
Name = "api-token",
SourceType = "inline",
Revision = 1
}
],
Bindings =
[
new CredentialBindingMetadata
{
Name = "api-binding",
Revision = 1,
Auth = new CredentialAuthMetadata
{
Type = "bearer"
}
}
]
};
}
}
private sealed class StubFiles : ISandboxFiles
{
public Task<IReadOnlyDictionary<string, SandboxFileInfo>> GetFileInfoAsync(IEnumerable<string> paths, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<IReadOnlyList<SandboxFileInfo>> SearchAsync(SearchEntry entry, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task CreateDirectoriesAsync(IEnumerable<CreateDirectoryEntry> entries, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task DeleteDirectoriesAsync(IEnumerable<string> paths, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<IReadOnlyList<SandboxFileInfo>> ListDirectoryAsync(string path, int? depth = null, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task WriteFilesAsync(IEnumerable<WriteEntry> entries, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<string> ReadFileAsync(string path, ReadFileOptions? options = null, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<byte[]> ReadBytesAsync(string path, ReadBytesOptions? options = null, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public IAsyncEnumerable<byte[]> ReadBytesStreamAsync(string path, ReadBytesOptions? options = null, CancellationToken cancellationToken = default) =>
AsyncEnumerable.Empty<byte[]>();
public Task DeleteFilesAsync(IEnumerable<string> paths, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task MoveFilesAsync(IEnumerable<MoveEntry> entries, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task ReplaceContentsAsync(IEnumerable<ContentReplaceEntry> entries, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<IReadOnlyList<ContentReplaceResult>> ReplaceContentsDetailedAsync(IEnumerable<ContentReplaceEntry> entries, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task SetPermissionsAsync(IEnumerable<SetPermissionEntry> entries, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
}
private sealed class StubHealth : IExecdHealth
{
public Task<bool> PingAsync(CancellationToken cancellationToken = default) => Task.FromResult(true);
}
private sealed class StubMetrics : IExecdMetrics
{
public Task<SandboxMetrics> GetMetricsAsync(CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
}
}