Skip to content

Commit

Permalink
correct artifact preview behavior in UI (#11059)
Browse files Browse the repository at this point in the history
This change allows KFP UI to fallback to UI host namespace when no
namespaces are provided when referencing the artifact object store
provider secret, in default kubeflow deployments this namespace is
simply "kubeflow", however the user can customize this behavior by
providing the environment variable "SERVER_NAMESPACE" to the KFP UI
deployment.

Further more, this change addresses a bug that caused URL
parse to fail when parsing endpoints without a protocol, this will
support such endpoint types as <ip>:<port> for object store endpoints,
as is the case in the default kfp deployment manifests.

Signed-off-by: Humair Khan <[email protected]>
  • Loading branch information
HumairAK authored Sep 3, 2024
1 parent 51d2c92 commit 2c91fb7
Show file tree
Hide file tree
Showing 6 changed files with 109 additions and 16 deletions.
2 changes: 2 additions & 0 deletions frontend/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ function createUIServer(options: UIConfigs) {
artifactsConfigs: options.artifacts,
useParameter: false,
tryExtract: true,
options: options,
}),
);
// /artifacts/ endpoint downloads the artifact as is, it does not try to unzip or untar.
Expand All @@ -158,6 +159,7 @@ function createUIServer(options: UIConfigs) {
artifactsConfigs: options.artifacts,
useParameter: true,
tryExtract: false,
options: options,
}),
);

Expand Down
4 changes: 4 additions & 0 deletions frontend/server/configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export function loadConfigs(argv: string[], env: ProcessEnv): UIConfigs {
* e.g. a valid header value for default values can be like `accounts.google.com:[email protected]`.
*/
KUBEFLOW_USERID_PREFIX = 'accounts.google.com:',
FRONTEND_SERVER_NAMESPACE = 'kubeflow',
} = env;

return {
Expand Down Expand Up @@ -190,6 +191,7 @@ export function loadConfigs(argv: string[], env: ProcessEnv): UIConfigs {
: asBool(HIDE_SIDENAV),
port,
staticDir,
serverNamespace: FRONTEND_SERVER_NAMESPACE,
},
viewer: {
tensorboard: {
Expand Down Expand Up @@ -266,6 +268,8 @@ export interface ServerConfigs {
apiVersion2Prefix: string;
deployment: Deployments;
hideSideNav: boolean;
// Namespace where the server is deployed
serverNamespace: string;
}
export interface GkeMetadataConfigs {
disabled: boolean;
Expand Down
12 changes: 8 additions & 4 deletions frontend/server/handlers/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import fetch from 'node-fetch';
import { AWSConfigs, HttpConfigs, MinioConfigs, ProcessEnv } from '../configs';
import { AWSConfigs, HttpConfigs, MinioConfigs, ProcessEnv, UIConfigs } from '../configs';
import { Client as MinioClient } from 'minio';
import { PreviewStream, findFileOnPodVolume, parseJSONString } from '../utils';
import { createMinioClient, getObjectStream } from '../minio-helper';
Expand Down Expand Up @@ -80,6 +80,7 @@ export function getArtifactsHandler({
artifactsConfigs,
useParameter,
tryExtract,
options,
}: {
artifactsConfigs: {
aws: AWSConfigs;
Expand All @@ -89,15 +90,18 @@ export function getArtifactsHandler({
};
tryExtract: boolean;
useParameter: boolean;
options: UIConfigs;
}): Handler {
const { aws, http, minio, allowedDomain } = artifactsConfigs;
return async (req, res) => {
const source = useParameter ? req.params.source : req.query.source;
const bucket = useParameter ? req.params.bucket : req.query.bucket;
const key = useParameter ? req.params[0] : req.query.key;
const { peek = 0, providerInfo = '', namespace = '' } = req.query as Partial<
ArtifactsQueryStrings
>;
const {
peek = 0,
providerInfo = '',
namespace = options.server.serverNamespace,
} = req.query as Partial<ArtifactsQueryStrings>;
if (!source) {
res.status(500).send('Storage source is missing from artifact request');
return;
Expand Down
95 changes: 85 additions & 10 deletions frontend/server/integration-tests/artifact-get.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,11 @@ describe('/artifacts', () => {
});
});

it('responds error when source is s3, and creds are sourced from Provider Configs, but no namespace is provided', done => {
it('responds with artifact if source is AWS S3, and creds are sourced from Provider Configs, and uses default kubeflow namespace when no namespace is provided', done => {
const mockedGetK8sSecret: jest.Mock = getK8sSecret as any;
mockedGetK8sSecret.mockResolvedValue('somevalue');
const mockedMinioClient: jest.Mock = minio.Client as any;
const namespace = 'kubeflow';
const configs = loadConfigs(argv, {});
app = new UIServer(configs);
const request = requests(app.start());
Expand All @@ -203,18 +206,90 @@ describe('/artifacts', () => {
};
request
.get(
`/artifacts/get?source=s3&bucket=ml-pipeline&key=hello%2Fworld.txt$&providerInfo=${JSON.stringify(
`/artifacts/get?source=s3&bucket=ml-pipeline&key=hello%2Fworld.txt&providerInfo=${JSON.stringify(
providerInfo,
)}`,
)
.expect(200, artifactContent, err => {
expect(mockedMinioClient).toBeCalledWith({
accessKey: 'somevalue',
endPoint: 's3.amazonaws.com',
port: undefined,
region: 'us-east-2',
secretKey: 'somevalue',
useSSL: undefined,
});
expect(mockedMinioClient).toBeCalledTimes(1);
expect(mockedGetK8sSecret).toBeCalledTimes(2);
expect(mockedGetK8sSecret).toHaveBeenNthCalledWith(
1,
'aws-s3-creds',
'AWS_ACCESS_KEY_ID',
`${namespace}`,
);
expect(mockedGetK8sSecret).toHaveBeenNthCalledWith(
2,
'aws-s3-creds',
'AWS_SECRET_ACCESS_KEY',
`${namespace}`,
);
expect(mockedGetK8sSecret).toBeCalledTimes(2);
done(err);
});
});

it('responds with artifact if source is AWS S3, and creds are sourced from Provider Configs, and uses default namespace when no namespace is provided, as specified in ENV', done => {
const mockedGetK8sSecret: jest.Mock = getK8sSecret as any;
mockedGetK8sSecret.mockResolvedValue('somevalue');
const mockedMinioClient: jest.Mock = minio.Client as any;
const namespace = 'notkubeflow';
const configs = loadConfigs(argv, { FRONTEND_SERVER_NAMESPACE: namespace });
app = new UIServer(configs);
const request = requests(app.start());
const providerInfo = {
Params: {
accessKeyKey: 'AWS_ACCESS_KEY_ID',
disableSSL: 'false',
endpoint: 's3.amazonaws.com',
fromEnv: 'false',
region: 'us-east-2',
secretKeyKey: 'AWS_SECRET_ACCESS_KEY',
secretName: 'aws-s3-creds',
},
Provider: 's3',
};
request
.get(
`/artifacts/get?source=s3&bucket=ml-pipeline&key=hello%2Fworld.txt&providerInfo=${JSON.stringify(
providerInfo,
)}`,
)
.expect(
500,
'Failed to initialize Minio Client for S3 Provider: Error: Artifact Store provider given, but no namespace provided.',
err => {
expect(mockedGetK8sSecret).toBeCalledTimes(0);
done(err);
},
);
.expect(200, artifactContent, err => {
expect(mockedMinioClient).toBeCalledWith({
accessKey: 'somevalue',
endPoint: 's3.amazonaws.com',
port: undefined,
region: 'us-east-2',
secretKey: 'somevalue',
useSSL: undefined,
});
expect(mockedMinioClient).toBeCalledTimes(1);
expect(mockedGetK8sSecret).toBeCalledTimes(2);
expect(mockedGetK8sSecret).toHaveBeenNthCalledWith(
1,
'aws-s3-creds',
'AWS_ACCESS_KEY_ID',
`${namespace}`,
);
expect(mockedGetK8sSecret).toHaveBeenNthCalledWith(
2,
'aws-s3-creds',
'AWS_SECRET_ACCESS_KEY',
`${namespace}`,
);
expect(mockedGetK8sSecret).toBeCalledTimes(2);
done(err);
});
});

it('responds with artifact if source is s3-compatible, and creds are sourced from Provider Configs', done => {
Expand Down
8 changes: 6 additions & 2 deletions frontend/server/minio-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export async function createMinioClient(
}

// If using s3 and sourcing credentials from environment (currently only aws is supported)
if (providerType === 's3' && (!config.accessKey || !config.secretKey)) {
if (providerType === 's3' && !(config.accessKey && config.secretKey)) {
// AWS S3 with credentials from provider chain
if (isAWSS3Endpoint(config.endPoint)) {
try {
Expand Down Expand Up @@ -168,7 +168,11 @@ async function parseS3ProviderInfo(
config.useSSL = undefined;
} else {
if (providerInfo.Params.endpoint) {
const parseEndpoint = new URL(providerInfo.Params.endpoint);
const url = providerInfo.Params.endpoint;
// this is a bit of a hack to add support for endpoints without a protocol (required by WHATWG URL standard)
// example: <ip>:<port> format. In general should expect most endpoints to provide a protocol as serviced
// by the backend
const parseEndpoint = new URL(url.startsWith('http') ? url : `https://${url}`);
const host = parseEndpoint.hostname;
const port = parseEndpoint.port;
config.endPoint = host;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ spec:
key: secretkey
- name: ALLOW_CUSTOM_VISUALIZATIONS
value: "true"
- name: FRONTEND_SERVER_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
readinessProbe:
exec:
command:
Expand Down

0 comments on commit 2c91fb7

Please sign in to comment.