diff --git a/modal-go/app.go b/modal-go/app.go index 7569311..7d32fd4 100644 --- a/modal-go/app.go +++ b/modal-go/app.go @@ -82,5 +82,13 @@ func (app *App) CreateSandbox(image *Image, options SandboxOptions) (*Sandbox, e // ImageFromRegistry creates an Image from a registry tag. func (app *App) ImageFromRegistry(tag string) (*Image, error) { - return fromRegistryInternal(app, tag) + return fromRegistryInternal(app, tag, &pb.ImageRegistryConfig{}) +} + +// ImageFromAwsEcr creates an Image from an AWS ECR tag, and secret for auth. +func (app *App) ImageFromAwsEcr(tag string, secret *Secret) (*Image, error) { + imageRegistryConfig := &pb.ImageRegistryConfig{} + imageRegistryConfig.SetRegistryAuthType(pb.RegistryAuthType_REGISTRY_AUTH_TYPE_AWS) + imageRegistryConfig.SetSecretId(secret.SecretId) + return fromRegistryInternal(app, tag, imageRegistryConfig) } diff --git a/modal-go/image.go b/modal-go/image.go index 90dd0ca..cd02fb6 100644 --- a/modal-go/image.go +++ b/modal-go/image.go @@ -16,13 +16,14 @@ type Image struct { ctx context.Context } -func fromRegistryInternal(app *App, tag string) (*Image, error) { +func fromRegistryInternal(app *App, tag string, imageRegistryConfig *pb.ImageRegistryConfig) (*Image, error) { resp, err := client.ImageGetOrCreate( app.ctx, pb.ImageGetOrCreateRequest_builder{ AppId: app.AppId, Image: pb.Image_builder{ - DockerfileCommands: []string{`FROM ` + tag}, + DockerfileCommands: []string{`FROM ` + tag}, + ImageRegistryConfig: imageRegistryConfig, }.Build(), Namespace: pb.DeploymentNamespace_DEPLOYMENT_NAMESPACE_WORKSPACE, BuilderVersion: "2024.10", // TODO: make this configurable diff --git a/modal-go/secret.go b/modal-go/secret.go new file mode 100644 index 0000000..d566844 --- /dev/null +++ b/modal-go/secret.go @@ -0,0 +1,38 @@ +package modal + +import ( + "context" + + pb "github.com/modal-labs/libmodal/modal-go/proto/modal_proto" +) + +// Secret represents a Modal secret. +type Secret struct { + SecretId string + + //lint:ignore U1000 may be used in future + ctx context.Context +} + +// SecretFromNameOptions are options for finding Modal secrets. +type SecretFromNameOptions struct { + Environment string + RequiredKeys []string +} + +func SecretFromName(ctx context.Context, name string, options SecretFromNameOptions) (*Secret, error) { + ctx = clientContext(ctx) + + resp, err := client.SecretGetOrCreate(ctx, pb.SecretGetOrCreateRequest_builder{ + DeploymentName: name, + Namespace: pb.DeploymentNamespace_DEPLOYMENT_NAMESPACE_WORKSPACE, + EnvironmentName: environmentName(options.Environment), + RequiredKeys: options.RequiredKeys, + }.Build()) + + if err != nil { + return nil, err + } + + return &Secret{SecretId: resp.GetSecretId()}, nil +} diff --git a/modal-go/test/secret_test.go b/modal-go/test/secret_test.go new file mode 100644 index 0000000..d8e2a50 --- /dev/null +++ b/modal-go/test/secret_test.go @@ -0,0 +1,46 @@ +package test + +import ( + "context" + "testing" + + "github.com/modal-labs/libmodal/modal-go" + "github.com/onsi/gomega" +) + +func TestSecretFromName(t *testing.T) { + t.Parallel() + g := gomega.NewWithT(t) + secret, err := modal.SecretFromName(context.Background(), "test-secret", modal.SecretFromNameOptions{}) + g.Expect(err).ShouldNot(gomega.HaveOccurred()) + g.Expect(secret.SecretId).Should(gomega.HavePrefix("st-")) + + _, err_missing := modal.SecretFromName(context.Background(), "missing-secret", modal.SecretFromNameOptions{}) + g.Expect(err_missing).Should(gomega.MatchError(gomega.ContainSubstring("Secret 'missing-secret' not found"))) + +} + +func TestSecretFromNameWithEnvironment(t *testing.T) { + t.Parallel() + g := gomega.NewWithT(t) + secret, err := modal.SecretFromName(context.Background(), "test-secret", modal.SecretFromNameOptions{ + Environment: "libmodal", + }) + g.Expect(err).ShouldNot(gomega.HaveOccurred()) + g.Expect(secret.SecretId).Should(gomega.HavePrefix("st-")) +} + +func TestSecretFromNameWithRequiredKeys(t *testing.T) { + t.Parallel() + g := gomega.NewWithT(t) + secret, err := modal.SecretFromName(context.Background(), "test-secret", modal.SecretFromNameOptions{ + RequiredKeys: []string{"a", "b", "c"}, + }) + g.Expect(err).ShouldNot(gomega.HaveOccurred()) + g.Expect(secret.SecretId).Should(gomega.HavePrefix("st-")) + + _, err_missing := modal.SecretFromName(context.Background(), "test-secret", modal.SecretFromNameOptions{ + RequiredKeys: []string{"a", "b", "c", "missing-key"}, + }) + g.Expect(err_missing).Should(gomega.MatchError(gomega.ContainSubstring("Secret is missing key(s): missing-key"))) +} diff --git a/modal-js/src/app.ts b/modal-js/src/app.ts index 44e8a0f..0bec20c 100644 --- a/modal-js/src/app.ts +++ b/modal-js/src/app.ts @@ -1,13 +1,16 @@ import { ClientError, Status } from "nice-grpc"; import { + ImageRegistryConfig, NetworkAccess_NetworkAccessType, ObjectCreationType, + RegistryAuthType, } from "../proto/modal_proto/api"; import { client } from "./client"; import { environmentName } from "./config"; import { fromRegistryInternal, Image } from "./image"; import { Sandbox } from "./sandbox"; import { NotFoundError } from "./errors"; +import { Secret } from "./secret"; export type LookupOptions = { environment?: string; @@ -82,4 +85,19 @@ export class App { async imageFromRegistry(tag: string): Promise { return await fromRegistryInternal(this.appId, tag); } + + async imageFromAwsEcr(tag: string, secret: Secret): Promise { + if (!secret.secretId) { + throw new Error( + "secret must be a reference to an existing Secret, e.g. `await Secret.fromName('my_secret')`", + ); + } + + const imageRegistryConfig = { + registryAuthType: RegistryAuthType.REGISTRY_AUTH_TYPE_AWS, + secretId: secret.secretId, + }; + + return await fromRegistryInternal(this.appId, tag, imageRegistryConfig); + } } diff --git a/modal-js/src/config.ts b/modal-js/src/config.ts index 4c3602c..b05e133 100644 --- a/modal-js/src/config.ts +++ b/modal-js/src/config.ts @@ -78,3 +78,7 @@ export const profile = getProfile(process.env["MODAL_PROFILE"] || undefined); export function environmentName(environment?: string): string { return environment || profile.environment || ""; } + +export function imageBuilderVersion(version?: string): string { + return version || process.env.MODAL_IMAGE_BUILDER_VERSION || "2024.10"; +} diff --git a/modal-js/src/image.ts b/modal-js/src/image.ts index b710c84..f751b01 100644 --- a/modal-js/src/image.ts +++ b/modal-js/src/image.ts @@ -3,8 +3,11 @@ import { GenericResult, GenericResult_GenericStatus, ImageMetadata, + ImageRegistryConfig, } from "../proto/modal_proto/api"; import { client } from "./client"; +import { imageBuilderVersion } from "./config"; +import { Secret } from "./secret"; export class Image { readonly imageId: string; @@ -17,14 +20,16 @@ export class Image { export async function fromRegistryInternal( appId: string, tag: string, + imageRegistryConfig?: ImageRegistryConfig, ): Promise { const resp = await client.imageGetOrCreate({ appId, image: { dockerfileCommands: [`FROM ${tag}`], + imageRegistryConfig: imageRegistryConfig, }, namespace: DeploymentNamespace.DEPLOYMENT_NAMESPACE_WORKSPACE, - builderVersion: "2024.10", // TODO: make this configurable + builderVersion: imageBuilderVersion(), }); let result: GenericResult; @@ -81,6 +86,5 @@ export async function fromRegistryInternal( `Image build for ${resp.imageId} failed with unknown status: ${result.status}`, ); } - return new Image(resp.imageId); } diff --git a/modal-js/src/index.ts b/modal-js/src/index.ts index 7010e84..af5be38 100644 --- a/modal-js/src/index.ts +++ b/modal-js/src/index.ts @@ -14,3 +14,4 @@ export { } from "./function_call"; export { Image } from "./image"; export { Sandbox, type StdioBehavior, type StreamMode } from "./sandbox"; +export { Secret } from "./secret"; diff --git a/modal-js/src/secret.ts b/modal-js/src/secret.ts new file mode 100644 index 0000000..5214b4d --- /dev/null +++ b/modal-js/src/secret.ts @@ -0,0 +1,43 @@ +import { DeploymentNamespace } from "../proto/modal_proto/api"; +import { client } from "./client"; +import { environmentName as configEnvironmentName } from "./config"; +import { ClientError, Status } from "nice-grpc"; +import { NotFoundError } from "./errors"; + +export type SecretFromNameOptions = { + environment?: string; + requiredKeys?: string[]; +}; + +export class Secret { + readonly secretId: string; + + constructor(secretId: string) { + this.secretId = secretId; + } + + static async fromName( + name: string, + options?: SecretFromNameOptions, + ): Promise { + try { + const resp = await client.secretGetOrCreate({ + deploymentName: name, + namespace: DeploymentNamespace.DEPLOYMENT_NAMESPACE_WORKSPACE, + environmentName: configEnvironmentName(options?.environment), + requiredKeys: options?.requiredKeys ?? [], + }); + return new Secret(resp.secretId); + } catch (err) { + if (err instanceof ClientError && err.code === Status.NOT_FOUND) + throw new NotFoundError(err.details); + if ( + err instanceof ClientError && + err.code === Status.FAILED_PRECONDITION && + err.details.includes("Secret is missing key") + ) + throw new NotFoundError(err.details); + throw err; + } + } +} diff --git a/modal-js/test/secret.test.ts b/modal-js/test/secret.test.ts new file mode 100644 index 0000000..b350fa3 --- /dev/null +++ b/modal-js/test/secret.test.ts @@ -0,0 +1,35 @@ +import { NotFoundError, Secret } from "modal"; +import { expect, test } from "vitest"; + +test("SecretFromName", async () => { + const secret = await Secret.fromName("test-secret"); + expect(secret).toBeDefined(); + expect(secret.secretId).toBeDefined(); + expect(secret.secretId).toMatch(/^st-/); + + const promise = Secret.fromName("missing-secret"); + await expect(promise).rejects.toThrowError( + /Secret 'missing-secret' not found/, + ); +}); + +test("SecretFromNameWithEnvironment", async () => { + const secret = await Secret.fromName("test-secret", { + environment: "libmodal", + }); + expect(secret).toBeDefined(); +}); + +test("SecretFromNameWithRequiredKeys", async () => { + const secret = await Secret.fromName("test-secret", { + requiredKeys: ["a", "b", "c"], + }); + expect(secret).toBeDefined(); + + const promise = Secret.fromName("test-secret", { + requiredKeys: ["a", "b", "c", "missing-key"], + }); + await expect(promise).rejects.toThrowError( + /Secret is missing key\(s\): missing-key/, + ); +});