Skip to content

Commit

Permalink
feat(cli): configure deployment in manifest (#9)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
jfeodor authored Jun 14, 2024
1 parent 6dcfcea commit b4fd668
Show file tree
Hide file tree
Showing 9 changed files with 215 additions and 85 deletions.
31 changes: 22 additions & 9 deletions cli/cmd/deploy/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
52 changes: 50 additions & 2 deletions cli/cmd/deploy/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
24 changes: 15 additions & 9 deletions cli/manifest/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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

Expand Down
167 changes: 103 additions & 64 deletions cli/manifest/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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",
Expand All @@ -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)

Expand All @@ -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)
}
})
}
Expand Down Expand Up @@ -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)
})
}
}
Loading

0 comments on commit b4fd668

Please sign in to comment.