This is an automated email from the ASF dual-hosted git repository.
zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git
The following commit(s) were added to refs/heads/main by this push:
new 2f4cb2831 fix(rest): take renewal expiry from the vended credential
(#2182)
2f4cb2831 is described below
commit 2f4cb2831b63ed7b217fed756a4fd4d0c2536424
Author: Revanth <[email protected]>
AuthorDate: Wed Oct 14 11:13:59 2026 -0500
fix(rest): take renewal expiry from the vended credential (#2182)
* fix(rest): take renewal expiry from the vended credential
* test(rest): pin vended expiry at Create, Register and Load
---
catalog/rest/refresh_table_credentials_test.go | 108 ++++++++++++++++++++-----
catalog/rest/rest.go | 21 +++--
catalog/rest/vended_creds.go | 45 ++++++-----
catalog/rest/vended_creds_test.go | 86 ++++++++++++++++----
4 files changed, 195 insertions(+), 65 deletions(-)
diff --git a/catalog/rest/refresh_table_credentials_test.go
b/catalog/rest/refresh_table_credentials_test.go
index 3170efd66..5f46bc899 100644
--- a/catalog/rest/refresh_table_credentials_test.go
+++ b/catalog/rest/refresh_table_credentials_test.go
@@ -24,10 +24,12 @@ import (
"net/http"
"net/http/httptest"
"net/url"
+ "strconv"
"strings"
"sync"
"sync/atomic"
"testing"
+ "time"
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/catalog"
@@ -580,9 +582,9 @@ func tableResponseFSProps(t *testing.T, scheme, metadataLoc
string, tableConfig,
// table-wide merge sites, where config is the whole table config rather than a
// per-location copy. The table's FileIO serves only the metadata location's
// account, so a vended adls.token drops what would apply to that account ahead
-// of it or set its lifetime: that account's SAS token and connection string,
-// any shared key, and every inherited credential expiry. Another account's
SAS token and
-// connection string never apply to it and are kept.
+// of it: that account's SAS token and connection string, and any shared key.
+// Another account's SAS token and connection string never apply to it and are
+// kept.
func TestTableResponseVendedADLSTokenClearsShadowingCredentials(t *testing.T) {
const (
scheme = "restcreds-table-adls"
@@ -595,15 +597,12 @@ func
TestTableResponseVendedADLSTokenClearsShadowingCredentials(t *testing.T) {
map[string]string{
// The factory would sign this account's requests with
another
// account's shared key.
- iceio.ADLSSharedKeyAccountName: "other",
- iceio.ADLSSharedKeyAccountKey:
"other-key",
- iceio.ADLSSasTokenPrefix + host:
"stale-sas",
- "adls.sas-token-expires-at-ms." + host: "1000",
- iceio.ADLSConnectionStringPrefix + "acct":
"stale-conn",
- iceio.ADLSSasTokenPrefix + otherHost:
"other-sas",
- "adls.sas-token-expires-at-ms." + otherHost: "1000",
- iceio.ADLSConnectionStringPrefix + "other":
"other-conn",
- "expiration-time": "1000",
+ iceio.ADLSSharedKeyAccountName: "other",
+ iceio.ADLSSharedKeyAccountKey: "other-key",
+ iceio.ADLSSasTokenPrefix + host: "stale-sas",
+ iceio.ADLSConnectionStringPrefix + "acct":
"stale-conn",
+ iceio.ADLSSasTokenPrefix + otherHost: "other-sas",
+ iceio.ADLSConnectionStringPrefix + "other":
"other-conn",
},
map[string]string{iceio.ADLSToken: "vended-token"})
@@ -617,18 +616,89 @@ func
TestTableResponseVendedADLSTokenClearsShadowingCredentials(t *testing.T) {
} {
assert.NotContains(t, props, k, "%s: credential %q
would be selected ahead of the token", name, k)
}
- for _, k := range []string{
- "adls.sas-token-expires-at-ms." + host,
- "adls.sas-token-expires-at-ms." + otherHost,
- "expiration-time",
- } {
- assert.NotContains(t, props, k, "%s: inherited expiry
%q would set the token's lifetime", name, k)
- }
assert.Equal(t, "other-sas",
props[iceio.ADLSSasTokenPrefix+otherHost], name)
assert.Equal(t, "other-conn",
props[iceio.ADLSConnectionStringPrefix+"other"], name)
}
}
+// TestTableResponseExpiryComesFromVendedCredential checks that a table's
FileIO
+// renews on the expiry of the credential vended with it, not on an expiry
+// inherited from the table config or on the default TTL. The inherited one has
+// already passed, so taking it would refetch credentials on every load; the
+// default TTL would keep an expired vended credential until the TTL elapses.
+func TestTableResponseExpiryComesFromVendedCredential(t *testing.T) {
+ const scheme = "restcreds-table-expiry"
+
+ rec := &ioPropsRecorder{}
+ rec.register(t, scheme)
+
+ metadataLoc := scheme +
"://warehouse/database/table/metadata/00000-a.metadata.json"
+
+ for _, ec := range []struct {
+ name string
+ vendedExpiry string
+ wantKey string
+ wantFetches int32
+ }{
+ {
+ name: "valid vend ignores stale config
expiries",
+ vendedExpiry:
strconv.FormatInt(time.Now().Add(time.Hour).UnixMilli(), 10),
+ wantKey: "vended-key",
+ },
+ {
+ // Already past, so the next FileIO use must go back to
the catalog.
+ name: "expired vend renews",
+ vendedExpiry: "1",
+ wantKey: "renewed-key",
+ wantFetches: 1,
+ },
+ } {
+ t.Run(ec.name, func(t *testing.T) {
+ respond := func(w http.ResponseWriter, _ *http.Request)
{
+ body :=
storageCredentialsBody(scheme+"://warehouse/database/table", map[string]string{
+ iceio.S3AccessKeyID:
"vended-key",
+ "s3.session-token-expires-at-ms":
ec.vendedExpiry,
+ })
+ body["metadata-location"] = metadataLoc
+ body["metadata"] =
json.RawMessage(strings.ReplaceAll(
+ exampleTableMetadataNoSnapshotV1,
"s3://", scheme+"://"))
+ body["config"] = map[string]string{
+ "gcs.oauth2.token-expires-at": "1000",
+ "expiration-time": "1000",
+ }
+ assert.NoError(t,
json.NewEncoder(w).Encode(body))
+ }
+
+ var credsCalls atomic.Int32
+ cat := newCredsTestCatalog(t, credsCatalogOpts{
+ creds: func(w http.ResponseWriter, _
*http.Request) {
+ credsCalls.Add(1)
+ assert.NoError(t,
json.NewEncoder(w).Encode(storageCredentialsBody(
+
scheme+"://warehouse/database/table",
+
map[string]string{iceio.S3AccessKeyID: "renewed-key"})))
+ },
+ loadTable: respond,
+ routes: map[string]http.HandlerFunc{
+ "/v1/namespaces/db/tables": respond,
+ "/v1/namespaces/db/register": respond,
+ },
+ })
+
+ for _, tc := range tableResponseCalls {
+ before := credsCalls.Load()
+ tbl, err := tc.call(context.Background(), cat)
+ require.NoError(t, err, tc.name)
+ for range 2 {
+ _, err = tbl.FS(context.Background())
+ require.NoError(t, err, tc.name)
+ }
+ assert.Equal(t, ec.wantFetches,
credsCalls.Load()-before, tc.name)
+ assert.Equal(t, ec.wantKey,
rec.lastLoad(t)[iceio.S3AccessKeyID], tc.name)
+ }
+ })
+ }
+}
+
// TestTableResponseLayersPartialS3Credential pins the existing behavior at the
// table-wide merge sites: a partial vended S3 credential is layered over the
// table config rather than replacing the key pair there. Whether a partial
diff --git a/catalog/rest/rest.go b/catalog/rest/rest.go
index e90c8599c..62024c49d 100644
--- a/catalog/rest/rest.go
+++ b/catalog/rest/rest.go
@@ -1287,6 +1287,7 @@ func (r *Catalog) tableFromResponse(
config iceberg.Properties,
scanPlanningConfig iceberg.Properties,
credsVended bool,
+ vended iceberg.Properties,
labels *iceberg.Labels,
opts ...table.Option,
) (*table.Table, error) {
@@ -1297,6 +1298,7 @@ func (r *Catalog) tableFromResponse(
identifier: identifier,
location: loc,
props: config,
+ vended: vended,
fetchCreds: func(ctx context.Context, ident []string)
(iceberg.Properties, error) {
return r.fetchTableCreds(ctx, ident, loc)
},
@@ -1413,7 +1415,7 @@ func (r *Catalog) RefreshTableCredentials(ctx
context.Context, tbl *table.Table)
opts = append(opts, table.WithMetricsReporter(reporter))
}
- return r.tableFromResponse(ctx, tbl.Identifier(), tbl.Metadata(),
metadataLoc, config, scanCfg, true, tbl.Labels(), opts...)
+ return r.tableFromResponse(ctx, tbl.Identifier(), tbl.Metadata(),
metadataLoc, config, scanCfg, true, resp, tbl.Labels(), opts...)
}
type identifierPageFetcher func(pageToken string) ([]table.Identifier, string,
error)
@@ -1636,9 +1638,10 @@ func (r *Catalog) CreateTable(ctx context.Context,
identifier table.Identifier,
maps.Copy(saved, ret.Config)
scanPlanningConfig := maps.Clone(config)
credsVended := len(ret.StorageCredentials) > 0
- mergeVendedCredentials(config,
resolveStorageCredentials(ret.StorageCredentials, ret.MetadataLoc),
ret.MetadataLoc)
+ vended := resolveStorageCredentials(ret.StorageCredentials,
ret.MetadataLoc)
+ mergeVendedCredentials(config, vended, ret.MetadataLoc)
- return r.tableFromResponse(ctx, identifier, ret.Metadata,
ret.MetadataLoc, config, scanPlanningConfig, credsVended, ret.Labels,
table.WithSavedConfig(saved))
+ return r.tableFromResponse(ctx, identifier, ret.Metadata,
ret.MetadataLoc, config, scanPlanningConfig, credsVended, vended, ret.Labels,
table.WithSavedConfig(saved))
}
// commitStagedCreate performs the second phase of a staged table
@@ -1866,9 +1869,10 @@ func (r *Catalog) RegisterTable(ctx context.Context,
identifier table.Identifier
maps.Copy(saved, ret.Config)
scanPlanningConfig := maps.Clone(config)
credsVended := len(ret.StorageCredentials) > 0
- mergeVendedCredentials(config,
resolveStorageCredentials(ret.StorageCredentials, ret.MetadataLoc),
ret.MetadataLoc)
+ vended := resolveStorageCredentials(ret.StorageCredentials,
ret.MetadataLoc)
+ mergeVendedCredentials(config, vended, ret.MetadataLoc)
- return r.tableFromResponse(ctx, identifier, ret.Metadata,
ret.MetadataLoc, config, scanPlanningConfig, credsVended, ret.Labels,
table.WithSavedConfig(saved))
+ return r.tableFromResponse(ctx, identifier, ret.Metadata,
ret.MetadataLoc, config, scanPlanningConfig, credsVended, vended, ret.Labels,
table.WithSavedConfig(saved))
}
// LoadTable loads a table from the catalog. It implements [catalog.Catalog].
@@ -1914,9 +1918,10 @@ func (r *Catalog) loadTableWithMode(ctx context.Context,
identifier table.Identi
maps.Copy(saved, ret.Config)
scanPlanningConfig := maps.Clone(config)
credsVended := len(ret.StorageCredentials) > 0
- mergeVendedCredentials(config,
resolveStorageCredentials(ret.StorageCredentials, ret.MetadataLoc),
ret.MetadataLoc)
+ vended := resolveStorageCredentials(ret.StorageCredentials,
ret.MetadataLoc)
+ mergeVendedCredentials(config, vended, ret.MetadataLoc)
- return r.tableFromResponse(ctx, identifier, ret.Metadata,
ret.MetadataLoc, config, scanPlanningConfig, credsVended, ret.Labels,
table.WithSavedConfig(saved))
+ return r.tableFromResponse(ctx, identifier, ret.Metadata,
ret.MetadataLoc, config, scanPlanningConfig, credsVended, vended, ret.Labels,
table.WithSavedConfig(saved))
}
func (r *Catalog) UpdateTable(ctx context.Context, ident table.Identifier,
requirements []table.Requirement, updates []table.Update) (*table.Table, error)
{
@@ -1965,7 +1970,7 @@ func (r *Catalog) UpdateTable(ctx context.Context, ident
table.Identifier, requi
maps.Copy(config, metadata.Properties())
// A commit response carries no labels (they are load-time enrichment).
- return r.tableFromResponse(ctx, ident, metadata, ret.MetadataLoc,
config, config, false, nil, table.WithSavedConfig(metadata.Properties()))
+ return r.tableFromResponse(ctx, ident, metadata, ret.MetadataLoc,
config, config, false, nil, nil, table.WithSavedConfig(metadata.Properties()))
}
func (r *Catalog) DropTable(ctx context.Context, identifier table.Identifier)
error {
diff --git a/catalog/rest/vended_creds.go b/catalog/rest/vended_creds.go
index e42c1c53f..76737a34c 100644
--- a/catalog/rest/vended_creds.go
+++ b/catalog/rest/vended_creds.go
@@ -126,6 +126,11 @@ type vendedCredentialRefresher struct {
identifier []string
location string
props iceberg.Properties
+ // vended is the credential config resolved for location at table load.
The
+ // first IO's expiry is read from it alone, and each renewal's from the
+ // credential that renewal fetched. props also carries catalog and table
+ // config, whose expiries belong to other credentials or to an earlier
vend.
+ vended iceberg.Properties
// credentials is populated only for scan-plan IO. Unlike table-load
// credentials, which are already resolved against the metadata
location,
// plan credentials must be selected against every file path opened
later.
@@ -155,6 +160,7 @@ func (v *vendedCredentialRefresher) loadFS(ctx
context.Context) (iceio.IO, error
}
var config iceberg.Properties
+ vended := v.vended
switch {
case v.cachedIO == nil:
config = v.props
@@ -180,6 +186,7 @@ func (v *vendedCredentialRefresher) loadFS(ctx
context.Context) (iceio.IO, error
config = maps.Clone(v.props)
mergeVendedCredentials(config, freshCreds, v.location)
+ vended = freshCreds
}
// The IO is cached and shared by every later caller, so it must not
@@ -220,7 +227,7 @@ func (v *vendedCredentialRefresher) loadFS(ctx
context.Context) (iceio.IO, error
}
newIO = loaded
- expiresAt = v.expiresAtFromConfig(config)
+ expiresAt = v.expiresAtFrom(config, vended)
issuedAt = v.now()
}
@@ -282,18 +289,25 @@ func (v *vendedCredentialRefresher) refreshBuffer()
time.Duration {
return buffer
}
-func (v *vendedCredentialRefresher) expiresAtFromConfig(config
iceberg.Properties) time.Time {
+// expiresAtFrom returns when the IO built from config expires. A refreshable
+// IO takes the expiry of vended, the credential it was built with, or the
+// default TTL without one: config's other expiries belong to other credentials
+// or an earlier vend, and an already-passed one would refetch on every load.
+func (v *vendedCredentialRefresher) expiresAtFrom(config, vended
iceberg.Properties) time.Time {
if len(v.credentials) > 0 {
return time.Time{}
}
- if exp, ok := parseCredentialExpiry(config); ok {
+ // No re-fetch to trigger, so the fallback TTL doesn't apply: without an
+ // expiry in config, never expires.
+ if v.fetchCreds == nil {
+ exp, _ := parseCredentialExpiry(config)
+
return exp
}
- // No re-fetch to trigger, so the fallback TTL doesn't apply: never
expires.
- if v.fetchCreds == nil {
- return time.Time{}
+ if exp, ok := parseCredentialExpiry(vended); ok {
+ return exp
}
return v.now().Add(defaultVendedCredentialsTTL)
@@ -450,17 +464,11 @@ func (p *prefixScopedIO) propertiesForLocation(name
string) iceberg.Properties {
// config, the properties an IO for location is built from. Every site that
// merges storage credentials goes through here.
//
-// A nonempty adls.token is the one case that also removes properties. config
is
+// A nonempty adls.token is the one case that also removes properties:
+// credentials the Azure FileIO selects ahead of the token would shadow it and
+// the read would 403; see adlsauth.ClearTokenShadowingCredentials. config is
// always a copy built for one IO, and the Azure FileIO serves only location's
-// storage account, so the removals cannot reach another account's IO:
-// - Credentials the Azure FileIO selects ahead of the token would shadow it
and
-// the read would 403; see adlsauth.ClearTokenShadowingCredentials.
-// - Every inherited credential expiry is dropped, whatever credential or
-// account it was for. The IO's renewal takes the earliest expiry in
config,
-// so any of them would set the token's lifetime in place of the expiry
-// vended with the token, or the default TTL without one. A server that
-// vends adls.token for a location outside Azure is misconfigured; its IO
-// loses inherited expiries too.
+// storage account, so the removals cannot reach another account's IO.
//
// Unlike the S3 and GCS clearing in clearOverriddenCredentialProperties, which
// keys on property presence, this keys on a nonempty value: the Azure FileIO
@@ -470,11 +478,6 @@ func mergeVendedCredentials(config, credentialConfig
iceberg.Properties, locatio
if credentialConfig[iceio.ADLSToken] != "" {
if acct, ok := adlsauth.AccountFor(location); ok {
adlsauth.ClearTokenShadowingCredentials(config, acct)
- for key := range config {
- if isCredentialExpiryKey(key) {
- delete(config, key)
- }
- }
}
}
diff --git a/catalog/rest/vended_creds_test.go
b/catalog/rest/vended_creds_test.go
index 615f1eaa5..463f71da5 100644
--- a/catalog/rest/vended_creds_test.go
+++ b/catalog/rest/vended_creds_test.go
@@ -20,6 +20,7 @@ package rest
import (
"context"
"errors"
+ "maps"
"net/url"
"strconv"
"sync"
@@ -291,10 +292,13 @@ func TestParseCredentialExpiry(t *testing.T) {
}
}
-func TestVendedCredsExpiresAtFromConfig(t *testing.T) {
+func TestVendedCredsExpiresAtFrom(t *testing.T) {
t.Parallel()
now := time.Now()
+ refreshable := func(context.Context, []string) (iceberg.Properties,
error) {
+ return nil, nil
+ }
t.Run("uses server-provided expiry", func(t *testing.T) {
t.Parallel()
@@ -306,23 +310,41 @@ func TestVendedCredsExpiresAtFromConfig(t *testing.T) {
mu: semaphore.NewWeighted(1),
nowFunc: func() time.Time { return now },
}
- got := r.expiresAtFromConfig(config)
+ got := r.expiresAtFrom(config, nil)
assert.Equal(t, serverExpiry.UnixMilli(), got.UnixMilli())
})
t.Run("refreshable creds fall back to default 60m when no expiry key",
func(t *testing.T) {
t.Parallel()
r := &vendedCredentialRefresher{
- mu: semaphore.NewWeighted(1),
- nowFunc: func() time.Time { return now },
- fetchCreds: func(context.Context, []string)
(iceberg.Properties, error) {
- return nil, nil
- },
+ mu: semaphore.NewWeighted(1),
+ nowFunc: func() time.Time { return now },
+ fetchCreds: refreshable,
}
- got := r.expiresAtFromConfig(iceberg.Properties{})
+ got := r.expiresAtFrom(iceberg.Properties{},
iceberg.Properties{})
assert.Equal(t, now.Add(60*time.Minute).UnixMilli(),
got.UnixMilli())
})
+ t.Run("refreshable creds take expiry from the vended credential only",
func(t *testing.T) {
+ t.Parallel()
+ vendedExpiry := now.Add(30 * time.Minute)
+ stale := strconv.FormatInt(now.Add(-time.Hour).UnixMilli(), 10)
+ config := iceberg.Properties{
+ keyGcsOAuthExpiresAt: stale,
+ keyExpirationTime: stale,
+ keyS3TokenExpiresAtMs:
strconv.FormatInt(vendedExpiry.UnixMilli(), 10),
+ }
+ r := &vendedCredentialRefresher{
+ mu: semaphore.NewWeighted(1),
+ nowFunc: func() time.Time { return now },
+ fetchCreds: refreshable,
+ }
+ got := r.expiresAtFrom(config, iceberg.Properties{
+ keyS3TokenExpiresAtMs:
strconv.FormatInt(vendedExpiry.UnixMilli(), 10),
+ })
+ assert.Equal(t, vendedExpiry.UnixMilli(), got.UnixMilli())
+ })
+
t.Run("non-refreshable creds without expiry never expire", func(t
*testing.T) {
t.Parallel()
// fetchCreds nil: no re-fetch to trigger, so no fallback TTL —
zero time.
@@ -330,11 +352,47 @@ func TestVendedCredsExpiresAtFromConfig(t *testing.T) {
mu: semaphore.NewWeighted(1),
nowFunc: func() time.Time { return now },
}
- got := r.expiresAtFromConfig(iceberg.Properties{})
+ got := r.expiresAtFrom(iceberg.Properties{}, nil)
assert.True(t, got.IsZero())
})
}
+// TestVendedCredsRenewalIgnoresEarlierVendExpiry checks that a renewal whose
+// credential has no expiry gets the default TTL. props still carries the first
+// vend's expiration-time, which has passed; taking it would refetch
credentials
+// and rebuild the IO on every load.
+func TestVendedCredsRenewalIgnoresEarlierVendExpiry(t *testing.T) {
+ t.Parallel()
+
+ now := time.Now()
+ firstVend := iceberg.Properties{
+ iceio.S3AccessKeyID: "first-key",
+ keyExpirationTime:
strconv.FormatInt(now.Add(-time.Minute).UnixMilli(), 10),
+ }
+
+ var fetches atomic.Int32
+ r := newTestRefresher(func(context.Context, []string)
(iceberg.Properties, error) {
+ fetches.Add(1)
+
+ return iceberg.Properties{iceio.S3AccessKeyID: "renewed-key"},
nil
+ })
+ r.nowFunc = func() time.Time { return now }
+ r.props = maps.Clone(firstVend)
+ r.vended = firstVend
+ // Force the refresh branch: a cached IO whose credentials have expired.
+ r.cachedIO = iceio.LocalFS{}
+ r.expiresAt = now.Add(-time.Minute)
+
+ _, err := r.loadFS(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, now.Add(defaultVendedCredentialsTTL).UnixMilli(),
r.expiresAt.UnixMilli())
+
+ _, err = r.loadFS(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, int32(1), fetches.Load(),
+ "the renewed credential is valid, so the next load must reuse
the IO")
+}
+
func TestVendedCredsServerExpiryUsedOnRefresh(t *testing.T) {
t.Parallel()
@@ -705,11 +763,8 @@ func TestPrefixScopedIOClearsStaleADLSAuthForVendedToken(t
*testing.T) {
iceio.ADLSSharedKeyAccountName: "acct",
iceio.ADLSSharedKeyAccountKey: "stale-key",
iceio.ADLSSasTokenPrefix + host: "stale-sas",
- keyAdlsSasExpiresAtMs + "." + host: "1000",
- keyAdlsSasExpiresAtMs: "1000",
iceio.ADLSConnectionStringPrefix + "acct":
"stale-connection-string",
iceio.ADLSSasTokenPrefix + otherHost: "other-sas",
- keyAdlsSasExpiresAtMs + "." + otherHost: "1000",
iceio.ADLSConnectionStringPrefix + "other":
"other-connection-string",
}, []StorageCredential{{
Prefix: "abfss://container@" + host + "/",
@@ -727,13 +782,10 @@ func
TestPrefixScopedIOClearsStaleADLSAuthForVendedToken(t *testing.T) {
assert.NotContains(t, props, iceio.ADLSSharedKeyAccountName)
assert.NotContains(t, props, iceio.ADLSSharedKeyAccountKey)
assert.NotContains(t, props, iceio.ADLSSasTokenPrefix+host)
- assert.NotContains(t, props, keyAdlsSasExpiresAtMs+"."+host)
- assert.NotContains(t, props, keyAdlsSasExpiresAtMs)
assert.NotContains(t, props, iceio.ADLSConnectionStringPrefix+"acct")
- // Another account's SAS expiry would still set this IO's expiry, so it
goes;
- // its SAS token and connection string never apply here and are kept.
- assert.NotContains(t, props, keyAdlsSasExpiresAtMs+"."+otherHost)
+ // Another account's SAS token and connection string never apply here
and
+ // are kept.
assert.Equal(t, "other-sas", props[iceio.ADLSSasTokenPrefix+otherHost])
assert.Equal(t, "other-connection-string",
props[iceio.ADLSConnectionStringPrefix+"other"])
}