From b4fd6680fa7bde8aa3d317279faa1b56672f24ca Mon Sep 17 00:00:00 2001 From: jfeodor <98314775+jfeodor@users.noreply.github.com> Date: Fri, 14 Jun 2024 13:26:47 +0200 Subject: [PATCH] feat(cli): configure deployment in manifest (#9) Add the ability to write a `deploy` section in `numerous.toml`, where `organization` and `name` (app name) can be defined, so that `numerous deploy` can be run without arguments. --- cli/cmd/deploy/deploy.go | 31 +++- cli/cmd/deploy/deploy_test.go | 52 +++++- cli/manifest/manifest.go | 24 ++- cli/manifest/manifest_test.go | 167 +++++++++++------- cli/testdata/streamlit_app/numerous.toml | 6 +- .../streamlit_app_without_deploy/app.py | 10 ++ .../app_cover.jpg | Bin 0 -> 2233 bytes .../numerous.toml | 9 + .../requirements.txt | 1 + 9 files changed, 215 insertions(+), 85 deletions(-) create mode 100644 cli/testdata/streamlit_app_without_deploy/app.py create mode 100644 cli/testdata/streamlit_app_without_deploy/app_cover.jpg create mode 100644 cli/testdata/streamlit_app_without_deploy/numerous.toml create mode 100644 cli/testdata/streamlit_app_without_deploy/requirements.txt diff --git a/cli/cmd/deploy/deploy.go b/cli/cmd/deploy/deploy.go index 61ca547..c1816e1 100644 --- a/cli/cmd/deploy/deploy.go +++ b/cli/cmd/deploy/deploy.go @@ -35,26 +35,39 @@ var ( ) func Deploy(ctx context.Context, apps AppService, appDir, projectDir, slug string, appName string, verbose bool) error { + task := output.StartTask("Loading app configuration") + manifest, err := manifest.LoadManifest(filepath.Join(appDir, manifest.ManifestPath)) + if err != nil { + task.Error() + output.PrintErrorAppNotInitialized(appDir) + + return err + } + + secrets := loadSecretsFromEnv(appDir) + + if slug == "" && manifest.Deployment != nil { + slug = manifest.Deployment.OrganizationSlug + } + if !validate.IsValidIdentifier(slug) { + task.Error() output.PrintError("Error: Invalid organization %q.", "Must contain only lower-case alphanumerical characters and dashes.", slug) + return ErrInvalidSlug } - if !validate.IsValidIdentifier(appName) { - output.PrintError("Error: Invalid app name %q.", "Must contain only lower-case alphanumerical characters and dashes.", appName) - return ErrInvalidAppName + if appName == "" && manifest.Deployment != nil { + appName = manifest.Deployment.AppName } - task := output.StartTask("Loading app configuration") - manifest, err := manifest.LoadManifest(filepath.Join(appDir, manifest.ManifestPath)) - if err != nil { + if !validate.IsValidIdentifier(appName) { task.Error() - output.PrintErrorAppNotInitialized(appDir) + output.PrintError("Error: Invalid app name %q.", "Must contain only lower-case alphanumerical characters and dashes.", appName) - return err + return ErrInvalidAppName } - secrets := loadSecretsFromEnv(appDir) task.Done() task = output.StartTask("Registering new version") diff --git a/cli/cmd/deploy/deploy_test.go b/cli/cmd/deploy/deploy_test.go index 57be4ed..84954c8 100644 --- a/cli/cmd/deploy/deploy_test.go +++ b/cli/cmd/deploy/deploy_test.go @@ -68,16 +68,64 @@ func TestDeploy(t *testing.T) { }) t.Run("given invalid slug then it returns error", func(t *testing.T) { - err := Deploy(context.TODO(), nil, ".", "", "Some Invalid Organization Slug", appName, false) + appDir := t.TempDir() + copyTo(t, "../../testdata/streamlit_app", appDir) + + err := Deploy(context.TODO(), nil, appDir, "", "Some Invalid Organization Slug", appName, false) assert.ErrorIs(t, err, ErrInvalidSlug) }) t.Run("given invalid app name then it returns error", func(t *testing.T) { - err := Deploy(context.TODO(), nil, ".", "", slug, "Some Invalid App Name", false) + appDir := t.TempDir() + copyTo(t, "../../testdata/streamlit_app", appDir) + + err := Deploy(context.TODO(), nil, appDir, "", slug, "Some Invalid App Name", false) assert.ErrorIs(t, err, ErrInvalidAppName) }) + + t.Run("given no slug or app name arguments and manifest with deployment and then it uses manifest deployment", func(t *testing.T) { + appDir := t.TempDir() + copyTo(t, "../../testdata/streamlit_app", appDir) + + apps := &mockAppService{} + apps.On("ReadApp", mock.Anything, mock.Anything).Return(app.ReadAppOutput{}, app.ErrAppNotFound) + apps.On("Create", mock.Anything, mock.Anything).Return(app.CreateAppOutput{AppID: appID}, nil) + apps.On("CreateVersion", mock.Anything, mock.Anything).Return(app.CreateAppVersionOutput{AppVersionID: appVersionID}, nil) + apps.On("AppVersionUploadURL", mock.Anything, mock.Anything).Return(app.AppVersionUploadURLOutput{UploadURL: uploadURL}, nil) + apps.On("UploadAppSource", mock.Anything, mock.Anything).Return(nil) + apps.On("DeployApp", mock.Anything, mock.Anything).Return(app.DeployAppOutput{DeploymentVersionID: deployVersionID}, nil) + apps.On("DeployEvents", mock.Anything, mock.Anything).Return(nil) + + err := Deploy(context.TODO(), apps, appDir, "", "", "", false) + + if assert.NoError(t, err) { + expectedInput := app.CreateAppInput{OrganizationSlug: "organization-slug-in-manifest", Name: "app-name-in-manifest", DisplayName: "Streamlit App With Deploy"} + apps.AssertCalled(t, "Create", mock.Anything, expectedInput) + } + }) + + t.Run("given slug or app name arguments and manifest with deployment and then arguments override manifest deployment", func(t *testing.T) { + appDir := t.TempDir() + copyTo(t, "../../testdata/streamlit_app", appDir) + + apps := &mockAppService{} + apps.On("ReadApp", mock.Anything, mock.Anything).Return(app.ReadAppOutput{}, app.ErrAppNotFound) + apps.On("Create", mock.Anything, mock.Anything).Return(app.CreateAppOutput{AppID: appID}, nil) + apps.On("CreateVersion", mock.Anything, mock.Anything).Return(app.CreateAppVersionOutput{AppVersionID: appVersionID}, nil) + apps.On("AppVersionUploadURL", mock.Anything, mock.Anything).Return(app.AppVersionUploadURLOutput{UploadURL: uploadURL}, nil) + apps.On("UploadAppSource", mock.Anything, mock.Anything).Return(nil) + apps.On("DeployApp", mock.Anything, mock.Anything).Return(app.DeployAppOutput{DeploymentVersionID: deployVersionID}, nil) + apps.On("DeployEvents", mock.Anything, mock.Anything).Return(nil) + + err := Deploy(context.TODO(), apps, appDir, "", "organization-slug-in-argument", "app-name-in-argument", false) + + if assert.NoError(t, err) { + expectedInput := app.CreateAppInput{OrganizationSlug: "organization-slug-in-argument", Name: "app-name-in-argument", DisplayName: "Streamlit App With Deploy"} + apps.AssertCalled(t, "Create", mock.Anything, expectedInput) + } + }) } func copyTo(t *testing.T, src string, dest string) { diff --git a/cli/manifest/manifest.go b/cli/manifest/manifest.go index c740c21..81a7d14 100644 --- a/cli/manifest/manifest.go +++ b/cli/manifest/manifest.go @@ -16,15 +16,16 @@ const ManifestFileName string = "numerous.toml" var ManifestPath string = filepath.Join(".", ManifestFileName) type Manifest struct { - Name string `toml:"name" json:"name"` - Description string `toml:"description" json:"description"` - Library Library `toml:"library" json:"library"` - Python string `toml:"python" json:"python"` - AppFile string `toml:"app_file" json:"app_file"` - RequirementsFile string `toml:"requirements_file" json:"requirements_file"` - Port uint `toml:"port" json:"port"` - CoverImage string `toml:"cover_image" json:"cover_image"` - Exclude []string `toml:"exclude" json:"exclude"` + Name string `toml:"name" json:"name"` + Description string `toml:"description" json:"description"` + Library Library `toml:"library" json:"library"` + Python string `toml:"python" json:"python"` + AppFile string `toml:"app_file" json:"app_file"` + RequirementsFile string `toml:"requirements_file" json:"requirements_file"` + Port uint `toml:"port" json:"port"` + CoverImage string `toml:"cover_image" json:"cover_image"` + Exclude []string `toml:"exclude" json:"exclude"` + Deployment *Deployment `toml:"deploy,omitempty" json:"deploy,omitempty"` } type DeprecatedManifest struct { @@ -39,6 +40,11 @@ type DeprecatedManifest struct { Exclude []string `toml:"exclude" json:"exclude"` } +type Deployment struct { + OrganizationSlug string `toml:"organization" json:"organization"` + AppName string `toml:"name" json:"name"` +} + func LoadManifest(filePath string) (*Manifest, error) { var m Manifest diff --git a/cli/manifest/manifest_test.go b/cli/manifest/manifest_test.go index 6680d34..da10934 100644 --- a/cli/manifest/manifest_test.go +++ b/cli/manifest/manifest_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/require" ) -const manifestTOML string = `name = "Tool Name" +const streamlitTOML string = `name = "Tool Name" description = "A description" library = "streamlit" python = "3.11" @@ -19,7 +19,12 @@ requirements_file = "requirements.txt" port = 80 cover_image = "cover.png" exclude = ["*venv", "venv*"] + +[deploy] + organization = "organization-slug" + name = "app-name" ` +const streamlitJSON string = `{"name":"Tool Name","description":"A description","library":"streamlit","python":"3.11","app_file":"app.py","requirements_file":"requirements.txt","port":80,"cover_image":"cover.png","exclude":["*venv","venv*"],"deploy":{"organization":"organization-slug","name":"app-name"}}` const deprecatedTOML string = `name = "Tool Name" description = "A description" @@ -31,13 +36,6 @@ port = "80" cover_image = "cover.png" exclude = ["*venv", "venv*"] ` -const manifestJSON string = `{"name":"Tool Name","description":"A description","library":"streamlit","python":"3.11","app_file":"app.py","requirements_file":"requirements.txt","port":80,"cover_image":"cover.png","exclude":["*venv","venv*"]}` - -type encodeTOMLTestCase struct { - name string - manifest Manifest - expectedTOML string -} var streamlitManifest = Manifest{ Name: "Tool Name", @@ -49,74 +47,115 @@ var streamlitManifest = Manifest{ RequirementsFile: "requirements.txt", CoverImage: "cover.png", Exclude: []string{"*venv", "venv*"}, + Deployment: &Deployment{OrganizationSlug: "organization-slug", AppName: "app-name"}, } -var encodeTOMLTestCases = []encodeTOMLTestCase{ - { - name: "streamlit app", - manifest: streamlitManifest, - expectedTOML: manifestTOML, - }, +const noDeployJSON string = `{"name":"Tool Name","description":"A description","library":"streamlit","python":"3.11","app_file":"app.py","requirements_file":"requirements.txt","port":80,"cover_image":"cover.png","exclude":["*venv","venv*"]}` + +const noDeployTOML string = `name = "Tool Name" +description = "A description" +library = "streamlit" +python = "3.11" +app_file = "app.py" +requirements_file = "requirements.txt" +port = 80 +cover_image = "cover.png" +exclude = ["*venv", "venv*"] +` + +var noDeployManifest = Manifest{ + Name: "Tool Name", + Description: "A description", + Library: LibraryStreamlit, + Python: "3.11", + Port: 80, + AppFile: "app.py", + RequirementsFile: "requirements.txt", + CoverImage: "cover.png", + Exclude: []string{"*venv", "venv*"}, } func TestTOMLEncoding(t *testing.T) { - for _, testcase := range encodeTOMLTestCases { - t.Run(testcase.name, func(t *testing.T) { - actual, err := testcase.manifest.ToToml() + testCases := []struct { + name string + manifest Manifest + expectedTOML string + }{ + { + name: "streamlit app", + manifest: streamlitManifest, + expectedTOML: streamlitTOML, + }, + { + name: "without default deployment", + manifest: noDeployManifest, + expectedTOML: noDeployTOML, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + actual, err := tc.manifest.ToToml() require.NoError(t, err) - assert.Equal(t, testcase.expectedTOML, actual) + assert.Equal(t, tc.expectedTOML, actual) }) } } -type encodeJSONTestCase struct { - name string - manifest Manifest - expectedJSON string -} - -var encodeJSONTestCases = []encodeJSONTestCase{ - { - name: "streamlit app", - manifest: streamlitManifest, - expectedJSON: manifestJSON, - }, -} - func TestJSONEncoding(t *testing.T) { - for _, testcase := range encodeJSONTestCases { - t.Run(testcase.name, func(t *testing.T) { - actual, err := testcase.manifest.ToJSON() + testCases := []struct { + name string + manifest Manifest + expectedJSON string + }{ + { + name: "streamlit app", + manifest: streamlitManifest, + expectedJSON: streamlitJSON, + }, + { + name: "without default deployment", + manifest: noDeployManifest, + expectedJSON: noDeployJSON, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + actual, err := tc.manifest.ToJSON() require.NoError(t, err) - assert.Equal(t, testcase.expectedJSON, actual) + assert.Equal(t, tc.expectedJSON, actual) }) } } -type decodeTOMLTestCase struct { - name string - tomlContent string - expected Manifest -} - -var decodeTOMLTestCases = []decodeTOMLTestCase{ - { - name: "streamlit with deprecated string port", - tomlContent: deprecatedTOML, - expected: streamlitManifest, - }, - { - name: "streamlit", - tomlContent: manifestTOML, - expected: streamlitManifest, - }, -} - func TestManifestDecodeTOML(t *testing.T) { - for _, testcase := range decodeTOMLTestCases { - t.Run(testcase.name, func(t *testing.T) { + testCases := []struct { + name string + tomlContent string + expected Manifest + }{ + { + name: "streamlit with deprecated string port", + tomlContent: deprecatedTOML, + expected: noDeployManifest, + }, + { + name: "streamlit", + tomlContent: streamlitTOML, + expected: streamlitManifest, + }, + { + name: "without default deployment", + tomlContent: noDeployTOML, + expected: noDeployManifest, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { // Save Manifest - filePath := test.WriteTempFile(t, ManifestFileName, []byte(testcase.tomlContent)) + filePath := test.WriteTempFile(t, ManifestFileName, []byte(tc.tomlContent)) defer os.Remove(filePath) @@ -125,7 +164,7 @@ func TestManifestDecodeTOML(t *testing.T) { require.NoError(t, err) if assert.NotNil(t, actual) { - assert.Equal(t, testcase.expected, *actual) + assert.Equal(t, tc.expected, *actual) } }) } @@ -190,17 +229,17 @@ func TestManifestValidateApp(t *testing.T) { }, } - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - appfile := test.WriteTempFile(t, "appfile.py", []byte(testCase.appfileContent)) - l, err := GetLibraryByKey(testCase.library) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + appfile := test.WriteTempFile(t, "appfile.py", []byte(tc.appfileContent)) + l, err := GetLibraryByKey(tc.library) require.NoError(t, err) m := Manifest{Library: l, AppFile: appfile} validated, err := m.ValidateApp() assert.NoError(t, err) - assert.Equal(t, testCase.expected, validated) + assert.Equal(t, tc.expected, validated) }) } } diff --git a/cli/testdata/streamlit_app/numerous.toml b/cli/testdata/streamlit_app/numerous.toml index 22801dc..250923d 100644 --- a/cli/testdata/streamlit_app/numerous.toml +++ b/cli/testdata/streamlit_app/numerous.toml @@ -1,4 +1,4 @@ -name = "streamlit_example" +name = "Streamlit App With Deploy" description = "" library = "streamlit" python = "3.11" @@ -7,3 +7,7 @@ requirements_file = "requirements.txt" port = 80 cover_image = "app_cover.jpg" exclude = ["*venv", "venv*", ".git"] + +[deploy] +organization = "organization-slug-in-manifest" +name = "app-name-in-manifest" diff --git a/cli/testdata/streamlit_app_without_deploy/app.py b/cli/testdata/streamlit_app_without_deploy/app.py new file mode 100644 index 0000000..822adda --- /dev/null +++ b/cli/testdata/streamlit_app_without_deploy/app.py @@ -0,0 +1,10 @@ +import streamlit as st + +st.title("Counter Example") +count = 0 + +increment = st.button("Increment") +if increment: + count += 1 + +st.write("Count = ", count) diff --git a/cli/testdata/streamlit_app_without_deploy/app_cover.jpg b/cli/testdata/streamlit_app_without_deploy/app_cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d86fbc92b9ad4aac920423c0767ae12ac763ac18 GIT binary patch literal 2233 zcmd^B`&ZK07RPIbXqFFr#Pu;E2G_yLF*H+25dv?cVs=Fdxno}w!W{M^$&ewjv|G})a&pLbWb3S{Yb=GHn)@G9f ziADx60}u#g?W;;ssUZM@^Qq!9G z>e$%rngpA@*8iyG9j*TFw2ykA^n7N0M@RnkhtXhc>G@yxB@5dv)!94N}C4 zF^3!0X;8VwxknGZOh@9}abb#{Gi22$7J`mv>O#IPTUYq9w zJwukpDuaD942F|)*2C6C7#kHF>Tr4_yU^`hp?T!hxNT1BsEm~TxK*mrU^vTCc{c%d zqWZ?r!Wiew(B{U%S!b|o_3?2lV7rzfkzo78^S&t|CkLdCf~R@2=Yu&_Cr@fn9}%OdAhx z{HDI2dCt4Fh87Kb=Eck^@Pe{0691y6*M}wJ#G@Ab**}G$-FZ~#e$ zhU-gQ5167kWUmk)fJj^srszVlmn+=Y4)}Kyhz1TFWRixEJo;{YRx3o(!`|g*%rP;8 zumD0hn(pyOogg8s3dyU#_ZPXF%>uI355L=ab6{`~IY1Rz4;Cd2ETYW#wJaMN}qZbhEb_)82NXp%rc_7V^gDEXq%+~f~Hcb z_(ThM>e&dPNF;jq@#Dv(ZY3-wCB=PpwsdA@=D3ll3c04$YHRbQDQ&g2wF`PpfT$`o zEu_tga;wI!5Msb|b9#DuVQy|NORFyd#lIT1ae8iLewNSHZoM87X2PW(nrNw4divfg zGeZ&g5Ugmt%fDeb>0DX4d0(9cJHa4k`SMZR@~-3oL4w{CY9`v8c}%Hz0P~$Z_f?D) lAX<^OdT<`-Pitk{x{qGA&p+)v@;xwVfcy!8_&Quv)*pNN#Ss7i literal 0 HcmV?d00001 diff --git a/cli/testdata/streamlit_app_without_deploy/numerous.toml b/cli/testdata/streamlit_app_without_deploy/numerous.toml new file mode 100644 index 0000000..a17de6b --- /dev/null +++ b/cli/testdata/streamlit_app_without_deploy/numerous.toml @@ -0,0 +1,9 @@ +name = "Streamlit App Without Deploy" +description = "" +library = "streamlit" +python = "3.11" +app_file = "app.py" +requirements_file = "requirements.txt" +port = 80 +cover_image = "app_cover.jpg" +exclude = ["*venv", "venv*", ".git"] diff --git a/cli/testdata/streamlit_app_without_deploy/requirements.txt b/cli/testdata/streamlit_app_without_deploy/requirements.txt new file mode 100644 index 0000000..12a4706 --- /dev/null +++ b/cli/testdata/streamlit_app_without_deploy/requirements.txt @@ -0,0 +1 @@ +streamlit