mirror of https://github.com/coder/coder.git
397 lines
13 KiB
Go
397 lines
13 KiB
Go
package agentapi_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/sqlc-dev/pqtype"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/mock/gomock"
|
|
"google.golang.org/protobuf/types/known/durationpb"
|
|
"tailscale.com/tailcfg"
|
|
|
|
agentproto "github.com/coder/coder/v2/agent/proto"
|
|
"github.com/coder/coder/v2/coderd/agentapi"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbmock"
|
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
|
"github.com/coder/coder/v2/coderd/externalauth"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/tailnet"
|
|
)
|
|
|
|
func TestGetManifest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
someTime, err := time.Parse(time.RFC3339, "2023-01-01T00:00:00Z")
|
|
require.NoError(t, err)
|
|
someTime = dbtime.Time(someTime)
|
|
|
|
expectedEnvVars := map[string]string{
|
|
"FOO": "bar",
|
|
"COOL_ENV": "dean was here",
|
|
}
|
|
expectedEnvVarsJSON, err := json.Marshal(expectedEnvVars)
|
|
require.NoError(t, err)
|
|
|
|
var (
|
|
owner = database.User{
|
|
ID: uuid.New(),
|
|
Username: "cool-user",
|
|
}
|
|
workspace = database.Workspace{
|
|
ID: uuid.New(),
|
|
OwnerID: owner.ID,
|
|
Name: "cool-workspace",
|
|
}
|
|
agent = database.WorkspaceAgent{
|
|
ID: uuid.New(),
|
|
Name: "cool-agent",
|
|
EnvironmentVariables: pqtype.NullRawMessage{
|
|
RawMessage: expectedEnvVarsJSON,
|
|
Valid: true,
|
|
},
|
|
Directory: "/cool/dir",
|
|
MOTDFile: "/cool/motd",
|
|
}
|
|
apps = []database.WorkspaceApp{
|
|
{
|
|
ID: uuid.New(),
|
|
Url: sql.NullString{String: "http://localhost:1234", Valid: true},
|
|
External: false,
|
|
Slug: "cool-app-1",
|
|
DisplayName: "app 1",
|
|
Command: sql.NullString{String: "cool command", Valid: true},
|
|
Icon: "/icon.png",
|
|
Subdomain: true,
|
|
SharingLevel: database.AppSharingLevelAuthenticated,
|
|
Health: database.WorkspaceAppHealthHealthy,
|
|
HealthcheckUrl: "http://localhost:1234/health",
|
|
HealthcheckInterval: 10,
|
|
HealthcheckThreshold: 3,
|
|
},
|
|
{
|
|
ID: uuid.New(),
|
|
Url: sql.NullString{String: "http://google.com", Valid: true},
|
|
External: true,
|
|
Slug: "google",
|
|
DisplayName: "Literally Google",
|
|
Command: sql.NullString{Valid: false},
|
|
Icon: "/google.png",
|
|
Subdomain: false,
|
|
SharingLevel: database.AppSharingLevelPublic,
|
|
Health: database.WorkspaceAppHealthDisabled,
|
|
},
|
|
{
|
|
ID: uuid.New(),
|
|
Url: sql.NullString{String: "http://localhost:4321", Valid: true},
|
|
External: true,
|
|
Slug: "cool-app-2",
|
|
DisplayName: "another COOL app",
|
|
Command: sql.NullString{Valid: false},
|
|
Icon: "",
|
|
Subdomain: false,
|
|
SharingLevel: database.AppSharingLevelOwner,
|
|
Health: database.WorkspaceAppHealthUnhealthy,
|
|
HealthcheckUrl: "http://localhost:4321/health",
|
|
HealthcheckInterval: 20,
|
|
HealthcheckThreshold: 5,
|
|
},
|
|
}
|
|
scripts = []database.WorkspaceAgentScript{
|
|
{
|
|
WorkspaceAgentID: agent.ID,
|
|
LogSourceID: uuid.New(),
|
|
LogPath: "/cool/log/path/1",
|
|
Script: "cool script 1",
|
|
Cron: "30 2 * * *",
|
|
StartBlocksLogin: true,
|
|
RunOnStart: true,
|
|
RunOnStop: false,
|
|
TimeoutSeconds: 60,
|
|
},
|
|
{
|
|
WorkspaceAgentID: agent.ID,
|
|
LogSourceID: uuid.New(),
|
|
LogPath: "/cool/log/path/2",
|
|
Script: "cool script 2",
|
|
Cron: "",
|
|
StartBlocksLogin: false,
|
|
RunOnStart: false,
|
|
RunOnStop: true,
|
|
TimeoutSeconds: 30,
|
|
},
|
|
}
|
|
metadata = []database.WorkspaceAgentMetadatum{
|
|
{
|
|
WorkspaceAgentID: agent.ID,
|
|
DisplayName: "cool metadata 1",
|
|
Key: "cool-key-1",
|
|
Script: "cool script 1",
|
|
Value: "cool value 1",
|
|
Error: "",
|
|
Timeout: int64(time.Minute),
|
|
Interval: int64(time.Minute),
|
|
CollectedAt: someTime,
|
|
},
|
|
{
|
|
WorkspaceAgentID: agent.ID,
|
|
DisplayName: "cool metadata 2",
|
|
Key: "cool-key-2",
|
|
Script: "cool script 2",
|
|
Value: "cool value 2",
|
|
Error: "some uncool error",
|
|
Timeout: int64(5 * time.Second),
|
|
Interval: int64(20 * time.Minute),
|
|
CollectedAt: someTime.Add(time.Hour),
|
|
},
|
|
}
|
|
derpMapFn = func() *tailcfg.DERPMap {
|
|
return &tailcfg.DERPMap{
|
|
Regions: map[int]*tailcfg.DERPRegion{
|
|
1: {RegionName: "cool region"},
|
|
},
|
|
}
|
|
}
|
|
)
|
|
|
|
// These are done manually to ensure the conversion logic matches what a
|
|
// human expects.
|
|
var (
|
|
protoApps = []*agentproto.WorkspaceApp{
|
|
{
|
|
Id: apps[0].ID[:],
|
|
Url: apps[0].Url.String,
|
|
External: apps[0].External,
|
|
Slug: apps[0].Slug,
|
|
DisplayName: apps[0].DisplayName,
|
|
Command: apps[0].Command.String,
|
|
Icon: apps[0].Icon,
|
|
Subdomain: apps[0].Subdomain,
|
|
SubdomainName: fmt.Sprintf("%s--%s--%s--%s", apps[0].Slug, agent.Name, workspace.Name, owner.Username),
|
|
SharingLevel: agentproto.WorkspaceApp_AUTHENTICATED,
|
|
Healthcheck: &agentproto.WorkspaceApp_Healthcheck{
|
|
Url: apps[0].HealthcheckUrl,
|
|
Interval: durationpb.New(time.Duration(apps[0].HealthcheckInterval) * time.Second),
|
|
Threshold: apps[0].HealthcheckThreshold,
|
|
},
|
|
Health: agentproto.WorkspaceApp_HEALTHY,
|
|
},
|
|
{
|
|
Id: apps[1].ID[:],
|
|
Url: apps[1].Url.String,
|
|
External: apps[1].External,
|
|
Slug: apps[1].Slug,
|
|
DisplayName: apps[1].DisplayName,
|
|
Command: apps[1].Command.String,
|
|
Icon: apps[1].Icon,
|
|
Subdomain: false,
|
|
SubdomainName: "",
|
|
SharingLevel: agentproto.WorkspaceApp_PUBLIC,
|
|
Healthcheck: &agentproto.WorkspaceApp_Healthcheck{
|
|
Url: "",
|
|
Interval: durationpb.New(0),
|
|
Threshold: 0,
|
|
},
|
|
Health: agentproto.WorkspaceApp_DISABLED,
|
|
},
|
|
{
|
|
Id: apps[2].ID[:],
|
|
Url: apps[2].Url.String,
|
|
External: apps[2].External,
|
|
Slug: apps[2].Slug,
|
|
DisplayName: apps[2].DisplayName,
|
|
Command: apps[2].Command.String,
|
|
Icon: apps[2].Icon,
|
|
Subdomain: false,
|
|
SubdomainName: "",
|
|
SharingLevel: agentproto.WorkspaceApp_OWNER,
|
|
Healthcheck: &agentproto.WorkspaceApp_Healthcheck{
|
|
Url: apps[2].HealthcheckUrl,
|
|
Interval: durationpb.New(time.Duration(apps[2].HealthcheckInterval) * time.Second),
|
|
Threshold: apps[2].HealthcheckThreshold,
|
|
},
|
|
Health: agentproto.WorkspaceApp_UNHEALTHY,
|
|
},
|
|
}
|
|
protoScripts = []*agentproto.WorkspaceAgentScript{
|
|
{
|
|
LogSourceId: scripts[0].LogSourceID[:],
|
|
LogPath: scripts[0].LogPath,
|
|
Script: scripts[0].Script,
|
|
Cron: scripts[0].Cron,
|
|
RunOnStart: scripts[0].RunOnStart,
|
|
RunOnStop: scripts[0].RunOnStop,
|
|
StartBlocksLogin: scripts[0].StartBlocksLogin,
|
|
Timeout: durationpb.New(time.Duration(scripts[0].TimeoutSeconds) * time.Second),
|
|
},
|
|
{
|
|
LogSourceId: scripts[1].LogSourceID[:],
|
|
LogPath: scripts[1].LogPath,
|
|
Script: scripts[1].Script,
|
|
Cron: scripts[1].Cron,
|
|
RunOnStart: scripts[1].RunOnStart,
|
|
RunOnStop: scripts[1].RunOnStop,
|
|
StartBlocksLogin: scripts[1].StartBlocksLogin,
|
|
Timeout: durationpb.New(time.Duration(scripts[1].TimeoutSeconds) * time.Second),
|
|
},
|
|
}
|
|
protoMetadata = []*agentproto.WorkspaceAgentMetadata_Description{
|
|
{
|
|
DisplayName: metadata[0].DisplayName,
|
|
Key: metadata[0].Key,
|
|
Script: metadata[0].Script,
|
|
Interval: durationpb.New(time.Duration(metadata[0].Interval)),
|
|
Timeout: durationpb.New(time.Duration(metadata[0].Timeout)),
|
|
},
|
|
{
|
|
DisplayName: metadata[1].DisplayName,
|
|
Key: metadata[1].Key,
|
|
Script: metadata[1].Script,
|
|
Interval: durationpb.New(time.Duration(metadata[1].Interval)),
|
|
Timeout: durationpb.New(time.Duration(metadata[1].Timeout)),
|
|
},
|
|
}
|
|
)
|
|
|
|
t.Run("OK", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
mDB := dbmock.NewMockStore(gomock.NewController(t))
|
|
|
|
api := &agentapi.ManifestAPI{
|
|
AccessURL: &url.URL{Scheme: "https", Host: "example.com"},
|
|
AppHostname: "*--apps.example.com",
|
|
ExternalAuthConfigs: []*externalauth.Config{
|
|
{Type: string(codersdk.EnhancedExternalAuthProviderGitHub)},
|
|
{Type: "some-provider"},
|
|
{Type: string(codersdk.EnhancedExternalAuthProviderGitLab)},
|
|
},
|
|
DisableDirectConnections: true,
|
|
DerpForceWebSockets: true,
|
|
|
|
AgentFn: func(ctx context.Context) (database.WorkspaceAgent, error) {
|
|
return agent, nil
|
|
},
|
|
WorkspaceIDFn: func(ctx context.Context, _ *database.WorkspaceAgent) (uuid.UUID, error) {
|
|
return workspace.ID, nil
|
|
},
|
|
Database: mDB,
|
|
DerpMapFn: derpMapFn,
|
|
}
|
|
|
|
mDB.EXPECT().GetWorkspaceAppsByAgentID(gomock.Any(), agent.ID).Return(apps, nil)
|
|
mDB.EXPECT().GetWorkspaceAgentScriptsByAgentIDs(gomock.Any(), []uuid.UUID{agent.ID}).Return(scripts, nil)
|
|
mDB.EXPECT().GetWorkspaceAgentMetadata(gomock.Any(), database.GetWorkspaceAgentMetadataParams{
|
|
WorkspaceAgentID: agent.ID,
|
|
Keys: nil, // all
|
|
}).Return(metadata, nil)
|
|
mDB.EXPECT().GetWorkspaceByID(gomock.Any(), workspace.ID).Return(workspace, nil)
|
|
mDB.EXPECT().GetUserByID(gomock.Any(), workspace.OwnerID).Return(owner, nil)
|
|
|
|
got, err := api.GetManifest(context.Background(), &agentproto.GetManifestRequest{})
|
|
require.NoError(t, err)
|
|
|
|
expected := &agentproto.Manifest{
|
|
AgentId: agent.ID[:],
|
|
AgentName: agent.Name,
|
|
OwnerUsername: owner.Username,
|
|
WorkspaceId: workspace.ID[:],
|
|
WorkspaceName: workspace.Name,
|
|
GitAuthConfigs: 2, // two "enhanced" external auth configs
|
|
EnvironmentVariables: expectedEnvVars,
|
|
Directory: agent.Directory,
|
|
VsCodePortProxyUri: fmt.Sprintf("https://{{port}}--%s--%s--%s--apps.example.com", agent.Name, workspace.Name, owner.Username),
|
|
MotdPath: agent.MOTDFile,
|
|
DisableDirectConnections: true,
|
|
DerpForceWebsockets: true,
|
|
// tailnet.DERPMapToProto() is extensively tested elsewhere, so it's
|
|
// not necessary to manually recreate a big DERP map here like we
|
|
// did for apps and metadata.
|
|
DerpMap: tailnet.DERPMapToProto(derpMapFn()),
|
|
Scripts: protoScripts,
|
|
Apps: protoApps,
|
|
Metadata: protoMetadata,
|
|
}
|
|
|
|
// Log got and expected with spew.
|
|
// t.Log("got:\n" + spew.Sdump(got))
|
|
// t.Log("expected:\n" + spew.Sdump(expected))
|
|
|
|
require.Equal(t, expected, got)
|
|
})
|
|
|
|
t.Run("NoAppHostname", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
mDB := dbmock.NewMockStore(gomock.NewController(t))
|
|
|
|
api := &agentapi.ManifestAPI{
|
|
AccessURL: &url.URL{Scheme: "https", Host: "example.com"},
|
|
AppHostname: "",
|
|
ExternalAuthConfigs: []*externalauth.Config{
|
|
{Type: string(codersdk.EnhancedExternalAuthProviderGitHub)},
|
|
{Type: "some-provider"},
|
|
{Type: string(codersdk.EnhancedExternalAuthProviderGitLab)},
|
|
},
|
|
DisableDirectConnections: true,
|
|
DerpForceWebSockets: true,
|
|
|
|
AgentFn: func(ctx context.Context) (database.WorkspaceAgent, error) {
|
|
return agent, nil
|
|
},
|
|
WorkspaceIDFn: func(ctx context.Context, _ *database.WorkspaceAgent) (uuid.UUID, error) {
|
|
return workspace.ID, nil
|
|
},
|
|
Database: mDB,
|
|
DerpMapFn: derpMapFn,
|
|
}
|
|
|
|
mDB.EXPECT().GetWorkspaceAppsByAgentID(gomock.Any(), agent.ID).Return(apps, nil)
|
|
mDB.EXPECT().GetWorkspaceAgentScriptsByAgentIDs(gomock.Any(), []uuid.UUID{agent.ID}).Return(scripts, nil)
|
|
mDB.EXPECT().GetWorkspaceAgentMetadata(gomock.Any(), database.GetWorkspaceAgentMetadataParams{
|
|
WorkspaceAgentID: agent.ID,
|
|
Keys: nil, // all
|
|
}).Return(metadata, nil)
|
|
mDB.EXPECT().GetWorkspaceByID(gomock.Any(), workspace.ID).Return(workspace, nil)
|
|
mDB.EXPECT().GetUserByID(gomock.Any(), workspace.OwnerID).Return(owner, nil)
|
|
|
|
got, err := api.GetManifest(context.Background(), &agentproto.GetManifestRequest{})
|
|
require.NoError(t, err)
|
|
|
|
expected := &agentproto.Manifest{
|
|
AgentId: agent.ID[:],
|
|
AgentName: agent.Name,
|
|
OwnerUsername: owner.Username,
|
|
WorkspaceId: workspace.ID[:],
|
|
WorkspaceName: workspace.Name,
|
|
GitAuthConfigs: 2, // two "enhanced" external auth configs
|
|
EnvironmentVariables: expectedEnvVars,
|
|
Directory: agent.Directory,
|
|
VsCodePortProxyUri: "", // empty with no AppHost
|
|
MotdPath: agent.MOTDFile,
|
|
DisableDirectConnections: true,
|
|
DerpForceWebsockets: true,
|
|
// tailnet.DERPMapToProto() is extensively tested elsewhere, so it's
|
|
// not necessary to manually recreate a big DERP map here like we
|
|
// did for apps and metadata.
|
|
DerpMap: tailnet.DERPMapToProto(derpMapFn()),
|
|
Scripts: protoScripts,
|
|
Apps: protoApps,
|
|
Metadata: protoMetadata,
|
|
}
|
|
|
|
// Log got and expected with spew.
|
|
// t.Log("got:\n" + spew.Sdump(got))
|
|
// t.Log("expected:\n" + spew.Sdump(expected))
|
|
|
|
require.Equal(t, expected, got)
|
|
})
|
|
}
|