mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 13:11:49 +00:00
feat(appliance): backport all recent appliance changes (#64182)
Draft in case plan in https://linear.app/sourcegraph/issue/REL-309/release-process-for-appliance not agreed. Please see that first. Generated by: ``` git log --format=%H d47b4cc48b6ea27cf6b5a274b79a6a4c8f38cf8c..origin/main -- cmd/appliance internal/appliance docker-images/appliance-frontend | tac | xargs git cherry-pick ```d47b4cc48bbeing the commit we branched off main from to create the 5.5.x branch (https://buildkite.com/sourcegraph/sourcegraph/builds/281882). Commits (generated by `git log --format='- https://github.com/sourcegraph/sourcegraph/commit/%H' d47b4cc48b6ea27cf6b5a274b79a6a4c8f38cf8c..origin/main -- cmd/appliance internal/appliance docker-images/appliance-frontend | tac`): -a20b0650b4-b71c986c77-91864283bc-c88b57020f-0491839942-619fc57074-e81c39a834-a61f353e0e-0abef7b43d-0e391a964a-daae9adfb6-6e31f0f4cc-49a600220d-37cf4a7b7e-29fc613c37-255e6387cc-49b32fcf3a-9f4c160f91-3814fd7390-c68e92bc28-7e82c27ab5-98c6b9703f-a01ebad841-8c2d8da234-ebec72d7ed-d945f19285-84e28998e9## Test plan Tests pass. ## Changelog - Backport all recent appliance changes. The appliance is still pre-release. --------- Co-authored-by: Jacob Pleiness <jdpleiness@users.noreply.github.com> Co-authored-by: Anish Lakhwara <anish+github@lakhwara.com> Co-authored-by: Warren Gifford <warren@sourcegraph.com> Co-authored-by: Nelson Araujo <nelsonjr@users.noreply.github.com>
This commit is contained in:
parent
162d3836da
commit
d24e8fe7f3
@ -35,6 +35,7 @@ export const createJsContext = ({ sourcegraphBaseUrl }: { sourcegraphBaseUrl: st
|
||||
accessTokensExpirationDaysOptions: [7, 14, 30, 60, 90],
|
||||
allowSignup: true,
|
||||
batchChangesEnabled: true,
|
||||
applianceManaged: false,
|
||||
batchChangesDisableWebhooksWarning: false,
|
||||
batchChangesWebhookLogsEnabled: true,
|
||||
executorsEnabled: false,
|
||||
|
||||
@ -38,6 +38,7 @@ export const AdminSidebarItems: StoryFn = () => (
|
||||
batchChangesExecutionEnabled={true}
|
||||
batchChangesWebhookLogsEnabled={true}
|
||||
codeInsightsEnabled={true}
|
||||
applianceManaged={false}
|
||||
endUserOnboardingEnabled={false}
|
||||
/>
|
||||
<SiteAdminSidebar
|
||||
@ -48,6 +49,7 @@ export const AdminSidebarItems: StoryFn = () => (
|
||||
batchChangesExecutionEnabled={true}
|
||||
batchChangesWebhookLogsEnabled={true}
|
||||
codeInsightsEnabled={true}
|
||||
applianceManaged={false}
|
||||
endUserOnboardingEnabled={false}
|
||||
/>
|
||||
<SiteAdminSidebar
|
||||
@ -58,6 +60,7 @@ export const AdminSidebarItems: StoryFn = () => (
|
||||
batchChangesExecutionEnabled={false}
|
||||
batchChangesWebhookLogsEnabled={false}
|
||||
codeInsightsEnabled={true}
|
||||
applianceManaged={false}
|
||||
endUserOnboardingEnabled={false}
|
||||
/>
|
||||
<SiteAdminSidebar
|
||||
@ -68,6 +71,7 @@ export const AdminSidebarItems: StoryFn = () => (
|
||||
batchChangesExecutionEnabled={true}
|
||||
batchChangesWebhookLogsEnabled={true}
|
||||
codeInsightsEnabled={false}
|
||||
applianceManaged={false}
|
||||
endUserOnboardingEnabled={false}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@ -27,6 +27,7 @@ export const createJsContext = ({ sourcegraphBaseUrl }: { sourcegraphBaseUrl: st
|
||||
accessTokensExpirationDaysOptions: [7, 30, 60, 90],
|
||||
allowSignup: false,
|
||||
batchChangesEnabled: true,
|
||||
applianceManaged: false,
|
||||
batchChangesDisableWebhooksWarning: false,
|
||||
batchChangesWebhookLogsEnabled: true,
|
||||
codeInsightsEnabled: true,
|
||||
|
||||
@ -196,6 +196,11 @@ export interface SourcegraphContext extends Pick<Required<SiteConfiguration>, 'e
|
||||
|
||||
batchChangesWebhookLogsEnabled: boolean
|
||||
|
||||
/**
|
||||
* Whether this sourcegraph instance is managed by Appliance
|
||||
*/
|
||||
applianceManaged: boolean
|
||||
|
||||
/**
|
||||
* Whether Cody is enabled on this instance. Check
|
||||
* {@link SourcegraphContext.codyEnabledForCurrentUser} to see whether Cody is enabled for the
|
||||
|
||||
@ -268,6 +268,7 @@ export const routes: RouteObject[] = [
|
||||
sideBarGroups={props.siteAdminSideBarGroups}
|
||||
overviewComponents={props.siteAdminOverviewComponents}
|
||||
codeInsightsEnabled={window.context.codeInsightsEnabled}
|
||||
applianceManaged={window.context.applianceManaged}
|
||||
telemetryRecorder={props.platformContext.telemetryRecorder}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -59,6 +59,7 @@ export interface SiteAdminAreaRouteContext
|
||||
overviewComponents: readonly React.ComponentType<React.PropsWithChildren<{}>>[]
|
||||
|
||||
codeInsightsEnabled: boolean
|
||||
applianceManaged: boolean
|
||||
|
||||
endUserOnboardingEnabled: boolean
|
||||
}
|
||||
@ -77,6 +78,7 @@ interface SiteAdminAreaProps
|
||||
authenticatedUser: AuthenticatedUser
|
||||
isSourcegraphDotCom: boolean
|
||||
codeInsightsEnabled: boolean
|
||||
applianceManaged: boolean
|
||||
}
|
||||
|
||||
const sourcegraphOperatorSiteAdminMaintenanceBlockItems = new Set([
|
||||
@ -142,6 +144,7 @@ const AuthenticatedSiteAdminArea: React.FunctionComponent<React.PropsWithChildre
|
||||
telemetryService: props.telemetryService,
|
||||
telemetryRecorder: props.telemetryRecorder,
|
||||
codeInsightsEnabled: props.codeInsightsEnabled,
|
||||
applianceManaged: props.applianceManaged,
|
||||
endUserOnboardingEnabled,
|
||||
}
|
||||
|
||||
@ -161,6 +164,7 @@ const AuthenticatedSiteAdminArea: React.FunctionComponent<React.PropsWithChildre
|
||||
batchChangesExecutionEnabled={props.batchChangesExecutionEnabled}
|
||||
batchChangesWebhookLogsEnabled={props.batchChangesWebhookLogsEnabled}
|
||||
codeInsightsEnabled={props.codeInsightsEnabled}
|
||||
applianceManaged={props.applianceManaged}
|
||||
endUserOnboardingEnabled={endUserOnboardingEnabled}
|
||||
/>
|
||||
<div className="flex-bounded">
|
||||
|
||||
@ -15,6 +15,7 @@ export interface SiteAdminSideBarGroupContext extends BatchChangesProps {
|
||||
isSourcegraphDotCom: boolean
|
||||
codeInsightsEnabled: boolean
|
||||
endUserOnboardingEnabled: boolean
|
||||
applianceManaged: boolean
|
||||
}
|
||||
|
||||
export interface SiteAdminSideBarGroup extends NavGroupDescriptor<SiteAdminSideBarGroupContext> {}
|
||||
|
||||
@ -135,6 +135,12 @@ const maintenanceGroup: SiteAdminSideBarGroup = {
|
||||
{
|
||||
label: maintenanceGroupUpdatesItemLabel,
|
||||
to: '/site-admin/updates',
|
||||
condition: ({ applianceManaged }) => !applianceManaged,
|
||||
},
|
||||
{
|
||||
label: maintenanceGroupUpdatesItemLabel,
|
||||
to: '/appliance/updates',
|
||||
condition: ({ applianceManaged }) => applianceManaged,
|
||||
},
|
||||
{
|
||||
label: 'Documentation',
|
||||
|
||||
@ -11,7 +11,9 @@ go_library(
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
"//internal/appliance",
|
||||
"//internal/appliance/healthchecker",
|
||||
"//internal/appliance/reconciler",
|
||||
"//internal/appliance/selfupdate",
|
||||
"//internal/appliance/v1:appliance",
|
||||
"//internal/debugserver",
|
||||
"//internal/env",
|
||||
@ -23,6 +25,7 @@ go_library(
|
||||
"//lib/errors",
|
||||
"@com_github_sourcegraph_log//:log",
|
||||
"@com_github_sourcegraph_log_logr//:logr",
|
||||
"@io_k8s_apimachinery//pkg/types",
|
||||
"@io_k8s_client_go//rest",
|
||||
"@io_k8s_client_go//tools/clientcmd",
|
||||
"@io_k8s_client_go//util/homedir",
|
||||
|
||||
@ -16,13 +16,15 @@ import (
|
||||
type Config struct {
|
||||
env.BaseConfig
|
||||
|
||||
k8sConfig *rest.Config
|
||||
metrics metricsConfig
|
||||
grpc grpcConfig
|
||||
http httpConfig
|
||||
namespace string
|
||||
relregEndpoint string
|
||||
applianceVersion string
|
||||
k8sConfig *rest.Config
|
||||
metrics metricsConfig
|
||||
grpc grpcConfig
|
||||
http httpConfig
|
||||
namespace string
|
||||
relregEndpoint string
|
||||
applianceVersion string
|
||||
selfDeploymentName string
|
||||
noResourceRestrictions string
|
||||
}
|
||||
|
||||
func (c *Config) Load() {
|
||||
@ -43,10 +45,12 @@ func (c *Config) Load() {
|
||||
c.metrics.addr = c.Get("APPLIANCE_METRICS_ADDR", ":8734", "Appliance metrics server address.")
|
||||
c.metrics.secure = c.GetBool("APPLIANCE_METRICS_SECURE", "false", "Appliance metrics server uses https.")
|
||||
c.grpc.addr = c.Get("APPLIANCE_GRPC_ADDR", ":9000", "Appliance gRPC address.")
|
||||
c.http.addr = c.Get("APPLIANCE_HTTP_ADDR", ":8080", "Appliance http address.")
|
||||
c.http.addr = c.Get("APPLIANCE_HTTP_ADDR", ":8888", "Appliance http address.")
|
||||
c.namespace = c.Get("APPLIANCE_NAMESPACE", "default", "Namespace to monitor.")
|
||||
c.applianceVersion = c.Get("APPLIANCE_VERSION", version.Version(), "Version tag for the running appliance.")
|
||||
c.selfDeploymentName = c.Get("APPLIANCE_DEPLOYMENT_NAME", "", "Own deployment name for self-update. Default is to disable self-update.")
|
||||
c.relregEndpoint = c.Get("RELEASE_REGISTRY_ENDPOINT", releaseregistry.Endpoint, "Release registry endpoint.")
|
||||
c.noResourceRestrictions = c.Get("APPLIANCE_NO_RESOURCE_RESTRICTIONS", "false", "Remove all resource requests and limits from deployed resources. Only recommended for local development.")
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
|
||||
@ -6,11 +6,13 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
"google.golang.org/grpc"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/cache"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
@ -18,8 +20,11 @@ import (
|
||||
|
||||
"github.com/sourcegraph/log"
|
||||
sglogr "github.com/sourcegraph/log/logr"
|
||||
|
||||
"github.com/sourcegraph/sourcegraph/internal/appliance"
|
||||
"github.com/sourcegraph/sourcegraph/internal/appliance/healthchecker"
|
||||
"github.com/sourcegraph/sourcegraph/internal/appliance/reconciler"
|
||||
"github.com/sourcegraph/sourcegraph/internal/appliance/selfupdate"
|
||||
pb "github.com/sourcegraph/sourcegraph/internal/appliance/v1"
|
||||
"github.com/sourcegraph/sourcegraph/internal/grpc/defaults"
|
||||
"github.com/sourcegraph/sourcegraph/internal/observation"
|
||||
@ -44,7 +49,14 @@ func Start(ctx context.Context, observationCtx *observation.Context, ready servi
|
||||
|
||||
relregClient := releaseregistry.NewClient(config.relregEndpoint)
|
||||
|
||||
app, err := appliance.NewAppliance(k8sClient, relregClient, config.applianceVersion, config.namespace, logger)
|
||||
noResourceRestrictions := false
|
||||
noResourceRestrictions, err = strconv.ParseBool(config.noResourceRestrictions)
|
||||
if err != nil {
|
||||
logger.Error("parsing APPLIANCE_NO_RESOURCE_RESTRICTIONS as bool", log.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
app, err := appliance.NewAppliance(k8sClient, relregClient, config.applianceVersion, config.namespace, noResourceRestrictions, logger)
|
||||
if err != nil {
|
||||
logger.Error("failed to create appliance", log.Error(err))
|
||||
return err
|
||||
@ -67,10 +79,13 @@ func Start(ctx context.Context, observationCtx *observation.Context, ready servi
|
||||
return err
|
||||
}
|
||||
|
||||
beginHealthCheckLoop := make(chan struct{})
|
||||
|
||||
if err = (&reconciler.Reconciler{
|
||||
Client: mgr.GetClient(),
|
||||
Scheme: mgr.GetScheme(),
|
||||
Recorder: mgr.GetEventRecorderFor("sourcegraph-appliance"),
|
||||
Client: mgr.GetClient(),
|
||||
Scheme: mgr.GetScheme(),
|
||||
Recorder: mgr.GetEventRecorderFor("sourcegraph-appliance"),
|
||||
BeginHealthCheckLoop: beginHealthCheckLoop,
|
||||
}).SetupWithManager(mgr); err != nil {
|
||||
logger.Error("unable to create the appliance controller", log.Error(err))
|
||||
return err
|
||||
@ -92,6 +107,26 @@ func Start(ctx context.Context, observationCtx *observation.Context, ready servi
|
||||
|
||||
grpcServer := makeGRPCServer(logger, app)
|
||||
|
||||
selfUpdater := &selfupdate.SelfUpdate{
|
||||
Interval: time.Hour,
|
||||
Logger: logger.Scoped("SelfUpdate"),
|
||||
K8sClient: k8sClient,
|
||||
RelregClient: relregClient,
|
||||
DeploymentNames: config.selfDeploymentName,
|
||||
Namespace: config.namespace,
|
||||
}
|
||||
|
||||
probe := &healthchecker.PodProbe{K8sClient: k8sClient}
|
||||
healthChecker := &healthchecker.HealthChecker{
|
||||
Probe: probe,
|
||||
K8sClient: k8sClient,
|
||||
Logger: logger.Scoped("HealthChecker"),
|
||||
|
||||
ServiceName: types.NamespacedName{Name: "sourcegraph-frontend", Namespace: config.namespace},
|
||||
Interval: time.Minute,
|
||||
Graceperiod: time.Minute,
|
||||
}
|
||||
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
ctx = shutdownOnSignal(ctx)
|
||||
|
||||
@ -119,6 +154,18 @@ func Start(ctx context.Context, observationCtx *observation.Context, ready servi
|
||||
}
|
||||
return nil
|
||||
})
|
||||
g.Go(func() error {
|
||||
if err := healthChecker.ManageIngressFacingService(ctx, beginHealthCheckLoop, "app=sourcegraph-frontend", config.namespace); err != nil {
|
||||
logger.Error("problem running HealthChecker", log.Error(err))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if config.selfDeploymentName != "" {
|
||||
g.Go(func() error {
|
||||
return selfUpdater.Loop(ctx)
|
||||
})
|
||||
}
|
||||
g.Go(func() error {
|
||||
<-ctx.Done()
|
||||
grpcServer.GracefulStop()
|
||||
|
||||
@ -233,6 +233,7 @@ type JSContext struct {
|
||||
CodeIntelRankingDocumentReferenceCountsEnabled bool `json:"codeIntelRankingDocumentReferenceCountsEnabled"`
|
||||
|
||||
CodeInsightsEnabled bool `json:"codeInsightsEnabled"`
|
||||
ApplianceManaged bool `json:"applianceManaged"`
|
||||
CodeIntelligenceEnabled bool `json:"codeIntelligenceEnabled"`
|
||||
SearchContextsEnabled bool `json:"searchContextsEnabled"`
|
||||
NotebooksEnabled bool `json:"notebooksEnabled"`
|
||||
@ -436,6 +437,7 @@ func NewJSContextFromRequest(req *http.Request, db database.DB) JSContext {
|
||||
CodyRequiresVerifiedEmail: siteResolver.RequiresVerifiedEmailForCody(ctx),
|
||||
|
||||
CodeSearchEnabledOnInstance: codeSearchLicensed,
|
||||
ApplianceManaged: conf.IsApplianceManaged(),
|
||||
|
||||
ExecutorsEnabled: conf.ExecutorsEnabled(),
|
||||
CodeIntelAutoIndexingEnabled: conf.CodeIntelAutoIndexingEnabled(),
|
||||
|
||||
7
deps.bzl
7
deps.bzl
@ -6419,13 +6419,6 @@ def go_dependencies():
|
||||
sum = "h1:dH55ru2OQOIAKjZi5wwXjNnSfN0oXLFYkMQy908s+tU=",
|
||||
version = "v0.2.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_wagslane_go_password_validator",
|
||||
build_file_proto_mode = "disable_global",
|
||||
importpath = "github.com/wagslane/go-password-validator",
|
||||
sum = "h1:vfxOPzGHkz5S146HDpavl0cw1DSVP061Ry2PX0/ON6I=",
|
||||
version = "v0.3.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_wk8_go_ordered_map_v2",
|
||||
build_file_proto_mode = "disable_global",
|
||||
|
||||
@ -127,5 +127,6 @@ write_source_files(
|
||||
"//cmd/enterprise-portal/internal/subscriptionsservice:generate_mocks",
|
||||
"//dev/sg/internal/analytics:generate_mocks",
|
||||
"//cmd/symbols/internal/fetcher:generate_mocks",
|
||||
"//internal/releaseregistry/mocks:generate_mocks",
|
||||
],
|
||||
)
|
||||
|
||||
71
docker-images/appliance-frontend/BUILD.bazel
Normal file
71
docker-images/appliance-frontend/BUILD.bazel
Normal file
@ -0,0 +1,71 @@
|
||||
load("//dev:oci_defs.bzl", "image_repository", "oci_image", "oci_push", "oci_tarball")
|
||||
load("@rules_pkg//:pkg.bzl", "pkg_tar")
|
||||
load("@container_structure_test//:defs.bzl", "container_structure_test")
|
||||
load("//wolfi-images:defs.bzl", "wolfi_base")
|
||||
|
||||
filegroup(
|
||||
name = "config",
|
||||
srcs = ["nginx.conf"],
|
||||
)
|
||||
|
||||
filegroup(
|
||||
name = "init_script",
|
||||
srcs = ["init.sh"],
|
||||
)
|
||||
|
||||
pkg_tar(
|
||||
name = "tar_config",
|
||||
srcs = [":config"],
|
||||
package_dir = "/etc/nginx",
|
||||
)
|
||||
|
||||
pkg_tar(
|
||||
name = "tar_init_script",
|
||||
srcs = [":init_script"],
|
||||
package_dir = "/",
|
||||
)
|
||||
|
||||
oci_image(
|
||||
name = "image",
|
||||
base = ":base_image",
|
||||
entrypoint = [
|
||||
"/init.sh",
|
||||
"nginx",
|
||||
"-g",
|
||||
"daemon off;",
|
||||
],
|
||||
tars = [
|
||||
":tar_init_script",
|
||||
":tar_config",
|
||||
"//internal/appliance/frontend/maintenance:tar_config",
|
||||
"//internal/appliance/frontend/maintenance:tar_frontend",
|
||||
],
|
||||
user = "sourcegraph",
|
||||
)
|
||||
|
||||
oci_tarball(
|
||||
name = "image_tarball",
|
||||
image = ":image",
|
||||
repo_tags = ["appliance-frontend:candidate"],
|
||||
)
|
||||
|
||||
container_structure_test(
|
||||
name = "image_test",
|
||||
timeout = "short",
|
||||
configs = ["image_test.yaml"],
|
||||
driver = "docker",
|
||||
image = ":image",
|
||||
tags = [
|
||||
"exclusive",
|
||||
"requires-network",
|
||||
TAG_INFRA_DEVINFRA,
|
||||
],
|
||||
)
|
||||
|
||||
oci_push(
|
||||
name = "candidate_push",
|
||||
image = ":image",
|
||||
repository = image_repository("appliance-frontend"),
|
||||
)
|
||||
|
||||
wolfi_base()
|
||||
14
docker-images/appliance-frontend/image_test.yaml
Executable file
14
docker-images/appliance-frontend/image_test.yaml
Executable file
@ -0,0 +1,14 @@
|
||||
schemaVersion: "2.0.0"
|
||||
|
||||
commandTests:
|
||||
- name: "nginx is runnable"
|
||||
command: "nginx"
|
||||
args:
|
||||
- -v
|
||||
|
||||
- name: "not running as root"
|
||||
command: "/usr/bin/id"
|
||||
args:
|
||||
- -u
|
||||
excludedOutput: ["^0"]
|
||||
exitCode: 0
|
||||
18
docker-images/appliance-frontend/init.sh
Executable file
18
docker-images/appliance-frontend/init.sh
Executable file
@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
template_dir="${NGINX_ENVSUBST_TEMPLATE_DIR:-/etc/nginx/templates}"
|
||||
suffix="${NGINX_ENVSUBST_TEMPLATE_SUFFIX:-.template}"
|
||||
output_dir="${NGINX_ENVSUBST_OUTPUT_DIR:-/etc/nginx/conf.d}"
|
||||
filter="${NGINX_ENVSUBST_FILTER:-}"
|
||||
# shellcheck disable=SC2046
|
||||
defined_envs=$(printf "\${%s} " $(awk "END { for (name in ENVIRON) { print ( name ~ /${filter}/ ) ? name : \"\" } }" </dev/null))
|
||||
|
||||
for template in /etc/nginx/templates/*.template; do
|
||||
relative_path="${template#"$template_dir/"}"
|
||||
output_path="$output_dir/${relative_path%"$suffix"}"
|
||||
subdir=$(dirname "$relative_path")
|
||||
mkdir -p "$output_dir/$subdir"
|
||||
echo "Processing $template -> $output_path"
|
||||
envsubst "$defined_envs" <"$template" >"$output_path"
|
||||
done
|
||||
|
||||
exec "$@"
|
||||
16
docker-images/appliance-frontend/nginx.conf
Executable file
16
docker-images/appliance-frontend/nginx.conf
Executable file
@ -0,0 +1,16 @@
|
||||
worker_processes 1;
|
||||
error_log stderr warn;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include mime.types;
|
||||
default_type application/octet-stream;
|
||||
access_log off;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
include conf.d/*.conf;
|
||||
}
|
||||
5
go.mod
5
go.mod
@ -258,6 +258,7 @@ require (
|
||||
connectrpc.com/connect v1.16.2
|
||||
connectrpc.com/grpcreflect v1.2.0
|
||||
connectrpc.com/otelconnect v0.7.0
|
||||
dario.cat/mergo v1.0.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/ai/azopenai v0.5.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.2
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.4.0
|
||||
@ -275,7 +276,6 @@ require (
|
||||
github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0
|
||||
github.com/go-redis/redis/v8 v8.11.5
|
||||
github.com/go-redsync/redsync/v4 v4.13.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/google/go-containerregistry v0.16.1
|
||||
github.com/google/go-github/v48 v48.2.0
|
||||
github.com/google/go-github/v55 v55.0.0
|
||||
@ -320,7 +320,6 @@ require (
|
||||
github.com/sourcegraph/sourcegraph/monitoring v0.0.0-00010101000000-000000000000
|
||||
github.com/vektah/gqlparser/v2 v2.4.5
|
||||
github.com/vvakame/gcplogurl v0.2.0
|
||||
github.com/wagslane/go-password-validator v0.3.0
|
||||
go.opentelemetry.io/collector/config/confighttp v0.103.0
|
||||
go.opentelemetry.io/collector/config/configtelemetry v0.103.0
|
||||
go.opentelemetry.io/collector/config/configtls v0.103.0
|
||||
@ -342,7 +341,6 @@ require (
|
||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||
cloud.google.com/go/longrunning v0.5.6 // indirect
|
||||
cloud.google.com/go/trace v1.10.6 // indirect
|
||||
dario.cat/mergo v1.0.0 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2 // indirect
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1 // indirect
|
||||
@ -390,6 +388,7 @@ require (
|
||||
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/gofrs/uuid/v5 v5.0.0 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/cel-go v0.20.1 // indirect
|
||||
|
||||
2
go.sum
2
go.sum
@ -2432,8 +2432,6 @@ github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAh
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/vvakame/gcplogurl v0.2.0 h1:dH55ru2OQOIAKjZi5wwXjNnSfN0oXLFYkMQy908s+tU=
|
||||
github.com/vvakame/gcplogurl v0.2.0/go.mod h1:CFjKFlur6M+/2DoGZL67O1FqZxB42jiqCyl4cXAmjOU=
|
||||
github.com/wagslane/go-password-validator v0.3.0 h1:vfxOPzGHkz5S146HDpavl0cw1DSVP061Ry2PX0/ON6I=
|
||||
github.com/wagslane/go-password-validator v0.3.0/go.mod h1:TI1XJ6T5fRdRnHqHt14pvy1tNVnrwe7m3/f1f2fDphQ=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0/go.mod h1:IXCdmsXIht47RaVFLEdVnh1t+pgYtTAhQGj73kz+2DM=
|
||||
|
||||
@ -6,24 +6,14 @@ go_library(
|
||||
srcs = [
|
||||
"appliance.go",
|
||||
"auth.go",
|
||||
"embed.go",
|
||||
"errors.go",
|
||||
"grpc.go",
|
||||
"html.go",
|
||||
"json.go",
|
||||
"routes.go",
|
||||
"status.go",
|
||||
"versions.go",
|
||||
],
|
||||
embedsrcs = [
|
||||
"web/static/img/favicon.png",
|
||||
"web/static/script/htmx.min.js",
|
||||
"web/template/setup.gohtml",
|
||||
"web/static/css/bootstrap.min.css",
|
||||
"web/static/css/custom.css",
|
||||
"web/static/script/bootstrap.bundle.min.js",
|
||||
"web/template/layout.gohtml",
|
||||
"web/template/landing.gohtml",
|
||||
"web/template/error.gohtml",
|
||||
],
|
||||
importpath = "github.com/sourcegraph/sourcegraph/internal/appliance",
|
||||
visibility = ["//:__subpackages__"],
|
||||
deps = [
|
||||
@ -32,12 +22,12 @@ go_library(
|
||||
"//internal/releaseregistry",
|
||||
"//lib/errors",
|
||||
"//lib/pointers",
|
||||
"@com_github_golang_jwt_jwt_v5//:jwt",
|
||||
"@cat_dario_mergo//:mergo",
|
||||
"@com_github_gorilla_mux//:mux",
|
||||
"@com_github_life4_genesis//slices",
|
||||
"@com_github_masterminds_semver_v3//:semver",
|
||||
"@com_github_sourcegraph_log//:log",
|
||||
"@com_github_wagslane_go_password_validator//:go-password-validator",
|
||||
"@io_k8s_api//apps/v1:apps",
|
||||
"@io_k8s_api//core/v1:core",
|
||||
"@io_k8s_apimachinery//pkg/api/errors",
|
||||
"@io_k8s_apimachinery//pkg/apis/meta/v1:meta",
|
||||
@ -57,12 +47,17 @@ go_test(
|
||||
name = "appliance_test",
|
||||
srcs = [
|
||||
"auth_test.go",
|
||||
"json_test.go",
|
||||
"status_test.go",
|
||||
"versions_test.go",
|
||||
],
|
||||
embed = [":appliance"],
|
||||
deps = [
|
||||
"@com_github_golang_jwt_jwt_v5//:jwt",
|
||||
"@com_github_google_go_cmp//cmp",
|
||||
"@com_github_sourcegraph_log//:log",
|
||||
"@com_github_stretchr_testify//require",
|
||||
"@io_k8s_api//apps/v1:apps",
|
||||
"@io_k8s_api//core/v1:core",
|
||||
"@io_k8s_sigs_controller_runtime//pkg/client",
|
||||
],
|
||||
)
|
||||
|
||||
@ -2,17 +2,18 @@ package appliance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
|
||||
"dario.cat/mergo"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/yaml"
|
||||
|
||||
"github.com/sourcegraph/log"
|
||||
|
||||
"github.com/sourcegraph/sourcegraph/internal/appliance/config"
|
||||
pb "github.com/sourcegraph/sourcegraph/internal/appliance/v1"
|
||||
"github.com/sourcegraph/sourcegraph/internal/releaseregistry"
|
||||
@ -21,15 +22,15 @@ import (
|
||||
)
|
||||
|
||||
type Appliance struct {
|
||||
jwtSecret []byte
|
||||
adminPasswordBcrypt []byte
|
||||
|
||||
client client.Client
|
||||
namespace string
|
||||
status Status
|
||||
status config.Status
|
||||
sourcegraph *config.Sourcegraph
|
||||
releaseRegistryClient *releaseregistry.Client
|
||||
latestSupportedVersion string
|
||||
noResourceRestrictions bool
|
||||
logger log.Logger
|
||||
|
||||
// Embed the UnimplementedApplianceServiceServer structs to ensure forwards compatibility (if the service is
|
||||
@ -38,31 +39,20 @@ type Appliance struct {
|
||||
pb.UnimplementedApplianceServiceServer
|
||||
}
|
||||
|
||||
// Status is a Stage that an Appliance can be in.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusUnknown Status = "unknown"
|
||||
StatusSetup Status = "setup"
|
||||
StatusInstalling Status = "installing"
|
||||
|
||||
// Secret and key names
|
||||
dataSecretName = "appliance-data"
|
||||
dataSecretJWTSigningKeyKey = "jwt-signing-key"
|
||||
dataSecretEncryptedPasswordKey = "encrypted-admin-password"
|
||||
initialPasswordSecretName = "appliance-password"
|
||||
initialPasswordSecretPasswordKey = "password"
|
||||
)
|
||||
|
||||
func (s Status) String() string {
|
||||
return string(s)
|
||||
}
|
||||
|
||||
func NewAppliance(
|
||||
client client.Client,
|
||||
relregClient *releaseregistry.Client,
|
||||
latestSupportedVersion string,
|
||||
namespace string,
|
||||
noResourceRestrictions bool,
|
||||
logger log.Logger,
|
||||
) (*Appliance, error) {
|
||||
app := &Appliance{
|
||||
@ -70,7 +60,8 @@ func NewAppliance(
|
||||
releaseRegistryClient: relregClient,
|
||||
latestSupportedVersion: latestSupportedVersion,
|
||||
namespace: namespace,
|
||||
status: StatusSetup,
|
||||
status: config.StatusInstall,
|
||||
noResourceRestrictions: noResourceRestrictions,
|
||||
sourcegraph: &config.Sourcegraph{},
|
||||
logger: logger,
|
||||
}
|
||||
@ -109,13 +100,6 @@ func (a *Appliance) ensureBackingSecretKeysExist(ctx context.Context, secret *co
|
||||
if secret.Data == nil {
|
||||
secret.Data = map[string][]byte{}
|
||||
}
|
||||
if _, ok := secret.Data[dataSecretJWTSigningKeyKey]; !ok {
|
||||
jwtSigningKey, err := genRandomBytes(32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secret.Data[dataSecretJWTSigningKeyKey] = jwtSigningKey
|
||||
}
|
||||
|
||||
if _, ok := secret.Data[dataSecretEncryptedPasswordKey]; !ok {
|
||||
// Get admin-supplied password from separate secret, then delete it
|
||||
@ -150,88 +134,106 @@ func (a *Appliance) ensureBackingSecretKeysExist(ctx context.Context, secret *co
|
||||
}
|
||||
|
||||
func (a *Appliance) loadValuesFromSecret(secret *corev1.Secret) {
|
||||
a.jwtSecret = secret.Data[dataSecretJWTSigningKeyKey]
|
||||
a.adminPasswordBcrypt = secret.Data[dataSecretEncryptedPasswordKey]
|
||||
}
|
||||
|
||||
func genRandomBytes(length int) ([]byte, error) {
|
||||
randomBytes := make([]byte, length)
|
||||
bytesRead, err := rand.Read(randomBytes)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "reading random bytes")
|
||||
}
|
||||
if bytesRead != length {
|
||||
return nil, errors.Newf("expected to read %d random bytes, got %d", length, bytesRead)
|
||||
}
|
||||
return randomBytes, nil
|
||||
}
|
||||
|
||||
func (a *Appliance) GetCurrentVersion(ctx context.Context) string {
|
||||
return a.sourcegraph.Status.CurrentVersion
|
||||
}
|
||||
|
||||
func (a *Appliance) GetCurrentStatus(ctx context.Context) Status {
|
||||
func (a *Appliance) GetCurrentStatus(ctx context.Context) config.Status {
|
||||
return a.status
|
||||
}
|
||||
|
||||
func (a *Appliance) CreateConfigMap(ctx context.Context, name string) (*corev1.ConfigMap, error) {
|
||||
spec, err := yaml.Marshal(a.sourcegraph)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
func (a *Appliance) reconcileConfigMap(ctx context.Context, configMap *corev1.ConfigMap) error {
|
||||
existingCfgMapName := types.NamespacedName{Name: config.ConfigmapName, Namespace: a.namespace}
|
||||
existingCfgMap := &corev1.ConfigMap{}
|
||||
if err := a.client.Get(ctx, existingCfgMapName, existingCfgMap); err != nil {
|
||||
// Create the ConfigMap if not found
|
||||
if apierrors.IsNotFound(err) {
|
||||
spec, err := yaml.Marshal(a.sourcegraph)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to marshal configmap yaml")
|
||||
}
|
||||
|
||||
configMap := &corev1.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: a.namespace,
|
||||
Labels: map[string]string{
|
||||
cfgMap := &corev1.ConfigMap{}
|
||||
cfgMap.Name = config.ConfigmapName
|
||||
cfgMap.Namespace = a.namespace
|
||||
|
||||
cfgMap.Labels = map[string]string{
|
||||
"deploy": "sourcegraph",
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
}
|
||||
|
||||
cfgMap.Annotations = map[string]string{
|
||||
// required annotation for our controller filter.
|
||||
config.AnnotationKeyManaged: "true",
|
||||
},
|
||||
},
|
||||
Immutable: pointers.Ptr(false),
|
||||
Data: map[string]string{
|
||||
"spec": string(spec),
|
||||
},
|
||||
config.AnnotationKeyStatus: string(config.StatusUnknown),
|
||||
config.AnnotationConditions: "",
|
||||
}
|
||||
|
||||
if configMap.ObjectMeta.Annotations != nil {
|
||||
cfgMap.ObjectMeta.Annotations = configMap.ObjectMeta.Annotations
|
||||
}
|
||||
|
||||
cfgMap.Immutable = pointers.Ptr(false)
|
||||
cfgMap.Data = map[string]string{"spec": string(spec)}
|
||||
|
||||
return a.client.Create(ctx, cfgMap)
|
||||
}
|
||||
|
||||
return errors.Wrap(err, "getting configmap")
|
||||
}
|
||||
|
||||
if err := a.client.Create(ctx, configMap); err != nil {
|
||||
return nil, err
|
||||
// The configmap already exists, update with any changed values
|
||||
if err := mergo.Merge(existingCfgMap, configMap, mergo.WithOverride); err != nil {
|
||||
return errors.Wrap(err, "merging configmaps")
|
||||
}
|
||||
|
||||
return configMap, nil
|
||||
return a.client.Update(ctx, existingCfgMap)
|
||||
}
|
||||
|
||||
func (a *Appliance) GetConfigMap(ctx context.Context, name string) (*corev1.ConfigMap, error) {
|
||||
var applianceSpec corev1.ConfigMap
|
||||
err := a.client.Get(ctx, types.NamespacedName{Name: name, Namespace: a.namespace}, &applianceSpec)
|
||||
if apierrors.IsNotFound(err) {
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
// isSourcegraphFrontendReady is a "health check" that is used to be able to know when our backing sourcegraph
|
||||
// deployment is ready. This is a "quick and dirty" function and should be replaced with a more comprehensive
|
||||
// health check in the very near future.
|
||||
func (a *Appliance) isSourcegraphFrontendReady(ctx context.Context) (bool, error) {
|
||||
frontendDeploymentName := types.NamespacedName{Name: "sourcegraph-frontend", Namespace: a.namespace}
|
||||
frontendDeployment := &appsv1.Deployment{}
|
||||
if err := a.client.Get(ctx, frontendDeploymentName, frontendDeployment); err != nil {
|
||||
// If the frontend deployment is not found, we can assume it's not ready
|
||||
if apierrors.IsNotFound(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.Wrap(err, "fetching frontend deployment")
|
||||
}
|
||||
|
||||
return &applianceSpec, nil
|
||||
return IsObjectReady(frontendDeployment)
|
||||
}
|
||||
|
||||
func (a *Appliance) shouldSetupRun(ctx context.Context) (bool, error) {
|
||||
cfgMap, err := a.GetConfigMap(ctx, "sourcegraph-appliance")
|
||||
switch {
|
||||
case err != nil:
|
||||
return false, err
|
||||
case a.status == StatusInstalling:
|
||||
// configMap does not exist but is being created
|
||||
return false, nil
|
||||
case cfgMap == nil:
|
||||
// configMap does not exist
|
||||
return true, nil
|
||||
case cfgMap.Annotations[config.AnnotationKeyManaged] == "false":
|
||||
// appliance is not managed
|
||||
return false, nil
|
||||
default:
|
||||
return true, nil
|
||||
func (a *Appliance) getStatus(ctx context.Context) (config.Status, error) {
|
||||
configMapName := types.NamespacedName{Name: config.ConfigmapName, Namespace: a.namespace}
|
||||
configMap := &corev1.ConfigMap{}
|
||||
if err := a.client.Get(ctx, configMapName, configMap); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return config.StatusUnknown, nil
|
||||
}
|
||||
return config.StatusUnknown, err
|
||||
}
|
||||
|
||||
return config.Status(configMap.ObjectMeta.Annotations[config.AnnotationKeyStatus]), nil
|
||||
}
|
||||
|
||||
func (a *Appliance) setStatus(ctx context.Context, status config.Status) error {
|
||||
configMapName := types.NamespacedName{Name: config.ConfigmapName, Namespace: a.namespace}
|
||||
configMap := &corev1.ConfigMap{}
|
||||
if err := a.client.Get(ctx, configMapName, configMap); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
configMap.Annotations[config.AnnotationKeyStatus] = string(status)
|
||||
err := a.client.Update(ctx, configMap)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed set status")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -2,74 +2,22 @@ package appliance
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/sourcegraph/log"
|
||||
"github.com/sourcegraph/sourcegraph/lib/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
authCookieName = "applianceAuth"
|
||||
jwtClaimsValidUntilKey = "valid-until"
|
||||
authHeaderName = "admin-password"
|
||||
)
|
||||
|
||||
func (a *Appliance) CheckAuthorization(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
authCookie, err := req.Cookie(authCookieName)
|
||||
if err != nil {
|
||||
a.authRedirect(w, req, err)
|
||||
func (a *Appliance) checkAuthorization(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
userPass := r.Header.Get(authHeaderName)
|
||||
if err := bcrypt.CompareHashAndPassword(a.adminPasswordBcrypt, []byte(userPass)); err != nil {
|
||||
a.invalidAdminPasswordResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := jwt.Parse(authCookie.Value, func(token *jwt.Token) (any, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return a.jwtSecret, nil
|
||||
})
|
||||
if err != nil {
|
||||
a.authRedirect(w, req, err)
|
||||
return
|
||||
}
|
||||
if !token.Valid {
|
||||
a.authRedirect(w, req, errors.New("JWT is not valid"))
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
a.authRedirect(w, req, errors.New("JWT Claims are not a MapClaims"))
|
||||
return
|
||||
}
|
||||
validUntilStr, ok := claims[jwtClaimsValidUntilKey].(string)
|
||||
if !ok {
|
||||
err := errors.Newf("JWT does not contain a string field '%s'", jwtClaimsValidUntilKey)
|
||||
a.authRedirect(w, req, err)
|
||||
return
|
||||
}
|
||||
validUntil, err := time.Parse(time.RFC3339, validUntilStr)
|
||||
if err != nil {
|
||||
a.authRedirect(w, req, errors.Wrapf(err, "parsing %s field on JWT claims", jwtClaimsValidUntilKey))
|
||||
return
|
||||
}
|
||||
if time.Now().After(validUntil) {
|
||||
a.authRedirect(w, req, errors.Newf("JWT expired: %s", validUntil.String()))
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, req)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Appliance) authRedirect(w http.ResponseWriter, req *http.Request, err error) {
|
||||
a.logger.Info("admin authorization failed", log.Error(err))
|
||||
deletedCookie := &http.Cookie{
|
||||
Name: authCookieName,
|
||||
Value: "",
|
||||
Expires: time.Unix(0, 0),
|
||||
}
|
||||
http.SetCookie(w, deletedCookie)
|
||||
http.Redirect(w, req, "/appliance/login", http.StatusFound)
|
||||
}
|
||||
|
||||
@ -4,122 +4,64 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/sourcegraph/log"
|
||||
)
|
||||
|
||||
var appliance = &Appliance{
|
||||
jwtSecret: []byte("a-jwt-secret"),
|
||||
logger: log.NoOp(),
|
||||
}
|
||||
|
||||
func TestCheckAuthorization_CallsNextHandlerWhenValidJWTSupplied(t *testing.T) {
|
||||
validUntil := time.Now().Add(time.Hour).UTC()
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
jwtClaimsValidUntilKey: validUntil.Format(time.RFC3339),
|
||||
})
|
||||
tokenStr, err := token.SignedString(appliance.jwtSecret)
|
||||
require.NoError(t, err)
|
||||
|
||||
req, err := http.NewRequest("GET", "example.com", nil)
|
||||
require.NoError(t, err)
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: authCookieName,
|
||||
Value: tokenStr,
|
||||
Expires: validUntil,
|
||||
})
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
})
|
||||
respSpy := httptest.NewRecorder()
|
||||
appliance.CheckAuthorization(handler).ServeHTTP(respSpy, req)
|
||||
|
||||
require.Equal(t, http.StatusAccepted, respSpy.Code)
|
||||
}
|
||||
|
||||
func TestCheckAuthorization_RedirectsToErrorPageWhenNoCookieSupplied(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", "example.com", nil)
|
||||
require.NoError(t, err)
|
||||
assertDirectAndHandlerNotCalled(t, req)
|
||||
}
|
||||
|
||||
func TestCheckAuthorization_RedirectsToErrorPageWhenCookieContainsInvalidJWT(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", "example.com", nil)
|
||||
require.NoError(t, err)
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: authCookieName,
|
||||
Value: "not-a-jwt",
|
||||
Expires: time.Now().Add(time.Hour),
|
||||
})
|
||||
assertDirectAndHandlerNotCalled(t, req)
|
||||
}
|
||||
|
||||
func TestCheckAuthorization_RedirectsToErrorPageWhenCookieContainsJWTWithIncorrectSignature(t *testing.T) {
|
||||
validUntil := time.Now().Add(time.Hour).UTC()
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
jwtClaimsValidUntilKey: validUntil.Format(time.RFC3339),
|
||||
})
|
||||
tokenStr, err := token.SignedString([]byte("wrong-key!"))
|
||||
|
||||
require.NoError(t, err)
|
||||
req, err := http.NewRequest("GET", "example.com", nil)
|
||||
require.NoError(t, err)
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: authCookieName,
|
||||
Value: tokenStr,
|
||||
Expires: validUntil,
|
||||
})
|
||||
assertDirectAndHandlerNotCalled(t, req)
|
||||
}
|
||||
|
||||
func TestCheckAuthorization_RedirectsToErrorPageWhenCookieContainsJWTWithMalformedClaims(t *testing.T) {
|
||||
validUntil := time.Now().Add(time.Hour).UTC()
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"wrong-key": validUntil.Format(time.RFC3339),
|
||||
})
|
||||
tokenStr, err := token.SignedString(appliance.jwtSecret)
|
||||
|
||||
require.NoError(t, err)
|
||||
req, err := http.NewRequest("GET", "example.com", nil)
|
||||
require.NoError(t, err)
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: authCookieName,
|
||||
Value: tokenStr,
|
||||
Expires: validUntil,
|
||||
})
|
||||
assertDirectAndHandlerNotCalled(t, req)
|
||||
}
|
||||
|
||||
func TestCheckAuthorization_RedirectsToErrorPageWhenCookieContainsJWTWithExpiredValidity(t *testing.T) {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
jwtClaimsValidUntilKey: time.Now().Add(-1 * time.Hour).Format(time.RFC3339),
|
||||
})
|
||||
tokenStr, err := token.SignedString(appliance.jwtSecret)
|
||||
|
||||
require.NoError(t, err)
|
||||
req, err := http.NewRequest("GET", "example.com", nil)
|
||||
require.NoError(t, err)
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: authCookieName,
|
||||
Value: tokenStr,
|
||||
Expires: time.Now().Add(time.Hour),
|
||||
})
|
||||
assertDirectAndHandlerNotCalled(t, req)
|
||||
}
|
||||
|
||||
func assertDirectAndHandlerNotCalled(t *testing.T, req *http.Request) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
require.Fail(t, "next handler should not be called")
|
||||
})
|
||||
respSpy := httptest.NewRecorder()
|
||||
appliance.CheckAuthorization(handler).ServeHTTP(respSpy, req)
|
||||
|
||||
require.Equal(t, http.StatusFound, respSpy.Code)
|
||||
func TestCheckAuthorization(t *testing.T) {
|
||||
// Create a mock Appliance
|
||||
mockAppliance := &Appliance{
|
||||
adminPasswordBcrypt: []byte("$2y$10$o2gHR6vUX7XPQj8tjUfi/e0zel.kpgvdTdSUkQthO9hTYooDUuoay"), // bcrypt hash for "password123"
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
password string
|
||||
expectedStatus int
|
||||
shouldCallNextHandler bool
|
||||
}{
|
||||
{
|
||||
name: "Valid password",
|
||||
password: "password123",
|
||||
expectedStatus: http.StatusOK,
|
||||
shouldCallNextHandler: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid password",
|
||||
password: "wrongpassword",
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
shouldCallNextHandler: false,
|
||||
},
|
||||
{
|
||||
name: "Empty password",
|
||||
password: "",
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
shouldCallNextHandler: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
nextHandlerCalled := false
|
||||
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
nextHandlerCalled = true
|
||||
if !tt.shouldCallNextHandler {
|
||||
t.Error("Next handler should not be called after a 403")
|
||||
}
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest("GET", "/", nil)
|
||||
req.Header.Set(authHeaderName, tt.password)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler := mockAppliance.checkAuthorization(nextHandler)
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if status := rr.Code; status != tt.expectedStatus {
|
||||
t.Errorf("handler returned wrong status code: got %v want %v", status, tt.expectedStatus)
|
||||
}
|
||||
|
||||
if tt.expectedStatus == http.StatusUnauthorized && nextHandlerCalled {
|
||||
t.Error("Next handler was called after a 403 response")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ go_library(
|
||||
"prometheus/default.yml.gotmpl",
|
||||
"postgres/codeinsights.conf",
|
||||
"grafana/default.yml.gotmpl",
|
||||
"otel/agent.yaml",
|
||||
],
|
||||
importpath = "github.com/sourcegraph/sourcegraph/internal/appliance/config",
|
||||
tags = [TAG_INFRA_RELEASE],
|
||||
@ -25,6 +26,7 @@ go_library(
|
||||
"//lib/pointers",
|
||||
"@io_k8s_api//core/v1:core",
|
||||
"@io_k8s_apimachinery//pkg/apis/meta/v1:meta",
|
||||
"@io_k8s_sigs_controller_runtime//pkg/client",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@ -1,7 +1,37 @@
|
||||
package config
|
||||
|
||||
// Status is a point in the Appliance lifecycle that an Appliance can be in.
|
||||
type Status string
|
||||
|
||||
func (s Status) String() string {
|
||||
return string(s)
|
||||
}
|
||||
|
||||
const (
|
||||
AnnotationKeyManaged = "appliance.sourcegraph.com/managed"
|
||||
AnnotationKeyCurrentVersion = "appliance.sourcegraph.com/currentVersion"
|
||||
AnnotationKeyConfigHash = "appliance.sourcegraph.com/configHash"
|
||||
ConfigmapName = "sourcegraph-appliance"
|
||||
|
||||
AnnotationKeyManaged = "appliance.sourcegraph.com/managed"
|
||||
AnnotationConditions = "appliance.sourcegraph.com/conditions"
|
||||
AnnotationKeyCurrentVersion = "appliance.sourcegraph.com/currentVersion"
|
||||
AnnotationKeyConfigHash = "appliance.sourcegraph.com/configHash"
|
||||
AnnotationKeyShouldTakeOwnership = "appliance.sourcegraph.com/adopted"
|
||||
|
||||
// TODO set status on configmap to communicate it across reboots
|
||||
AnnotationKeyStatus = "appliance.sourcegraph.com/status"
|
||||
|
||||
StatusUnknown Status = "unknown"
|
||||
StatusInstall Status = "install"
|
||||
StatusInstalling Status = "installing"
|
||||
StatusUpgrading Status = "upgrading"
|
||||
StatusWaitingForAdmin Status = "wait-for-admin"
|
||||
StatusRefresh Status = "refresh"
|
||||
StatusMaintenance Status = "maintenance"
|
||||
)
|
||||
|
||||
func IsPostInstallStatus(status Status) bool {
|
||||
switch status {
|
||||
case StatusUnknown, StatusInstall, StatusInstalling, StatusWaitingForAdmin:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
package config
|
||||
|
||||
import corev1 "k8s.io/api/core/v1"
|
||||
import (
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
)
|
||||
|
||||
type StandardComponent interface {
|
||||
Disableable
|
||||
@ -63,3 +66,20 @@ func (c StandardConfig) GetPrometheusPort() *int { return c.Prom
|
||||
func (c StandardConfig) GetServiceAccountAnnotations() map[string]string {
|
||||
return c.ServiceAccountAnnotations
|
||||
}
|
||||
|
||||
func MarkObjectForAdoption(obj client.Object) {
|
||||
annotations := obj.GetAnnotations()
|
||||
if annotations == nil {
|
||||
annotations = map[string]string{}
|
||||
}
|
||||
annotations[AnnotationKeyShouldTakeOwnership] = "true"
|
||||
obj.SetAnnotations(annotations)
|
||||
}
|
||||
|
||||
func ShouldAdopt(obj client.Object) bool {
|
||||
if annotations := obj.GetAnnotations(); annotations != nil {
|
||||
_, ok := annotations[AnnotationKeyShouldTakeOwnership]
|
||||
return ok
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed otel/*
|
||||
//go:embed postgres/*
|
||||
//go:embed prometheus/default.yml.gotmpl
|
||||
//go:embed grafana/default.yml.gotmpl
|
||||
@ -15,6 +16,7 @@ var (
|
||||
GrafanaDefaultConfigTemplate []byte
|
||||
CodeIntelConfig []byte
|
||||
CodeInsightsConfig []byte
|
||||
OtelAgentConfig []byte
|
||||
)
|
||||
|
||||
func init() {
|
||||
@ -23,6 +25,7 @@ func init() {
|
||||
PgsqlConfig = mustReadFile("postgres/pgsql.conf")
|
||||
PrometheusDefaultConfigTemplate = mustReadFile("prometheus/default.yml.gotmpl")
|
||||
GrafanaDefaultConfigTemplate = mustReadFile("grafana/default.yml.gotmpl")
|
||||
OtelAgentConfig = mustReadFile("otel/agent.yaml")
|
||||
}
|
||||
|
||||
func mustReadFile(name string) []byte {
|
||||
|
||||
43
internal/appliance/config/otel/agent.yaml
Normal file
43
internal/appliance/config/otel/agent.yaml
Normal file
@ -0,0 +1,43 @@
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc: # port 4317
|
||||
http: # port 4318
|
||||
|
||||
exporters:
|
||||
otlp:
|
||||
endpoint: "otel-collector:4317"
|
||||
tls:
|
||||
insecure: true
|
||||
sending_queue:
|
||||
num_consumers: 4
|
||||
queue_size: 100
|
||||
retry_on_failure:
|
||||
enabled: true
|
||||
|
||||
# TODO: allow configuring processors through values
|
||||
#processors:
|
||||
# batch:
|
||||
# memory_limiter:
|
||||
# # 80% of maximum memory up to 2G
|
||||
# limit_mib: 400
|
||||
# # 25% of limit up to 2G
|
||||
# spike_limit_mib: 100
|
||||
# check_interval: 5s
|
||||
|
||||
extensions:
|
||||
health_check:
|
||||
endpoint: ":13133"
|
||||
zpages:
|
||||
endpoint: "localhost:55679"
|
||||
|
||||
service:
|
||||
extensions:
|
||||
- zpages
|
||||
- health_check
|
||||
pipelines:
|
||||
traces:
|
||||
receivers:
|
||||
- otlp
|
||||
exporters:
|
||||
- otlp
|
||||
@ -88,6 +88,10 @@ type IndexedSearchSpec struct {
|
||||
Replicas int32 `json:"replicas,omitempty"`
|
||||
}
|
||||
|
||||
type OtelAgentSpec struct {
|
||||
StandardConfig
|
||||
}
|
||||
|
||||
type OtelCollectorSpec struct {
|
||||
StandardConfig
|
||||
}
|
||||
@ -228,7 +232,8 @@ type SourcegraphSpec struct {
|
||||
|
||||
Jaeger JaegerSpec `json:"jaeger,omitempty"`
|
||||
|
||||
OtelCollector OtelCollectorSpec `json:"openTelemetry,omitempty"`
|
||||
OtelAgent OtelAgentSpec `json:"openTelemetryAgent,omitempty"`
|
||||
OtelCollector OtelCollectorSpec `json:"openTelemetryCollector,omitempty"`
|
||||
|
||||
// PGSQL defines the desired state of the PostgreSQL database.
|
||||
PGSQL PGSQLSpec `json:"pgsql,omitempty"`
|
||||
@ -264,21 +269,33 @@ type SourcegraphSpec struct {
|
||||
StorageClass StorageClassSpec `json:"storageClass,omitempty"`
|
||||
}
|
||||
|
||||
// SetupStatus defines the observes status of the setup process.
|
||||
type SetupStatus struct {
|
||||
Progress int32
|
||||
// SourcegraphServicesToReconcile is a list of all Sourcegraph services that will be reconciled by appliance.
|
||||
var SourcegraphServicesToReconcile = []string{
|
||||
"blobstore",
|
||||
"repo-updater",
|
||||
"symbols",
|
||||
"gitserver",
|
||||
"redis",
|
||||
"pgsql",
|
||||
"syntect",
|
||||
"precise-code-intel",
|
||||
"code-insights-db",
|
||||
"code-intel-db",
|
||||
"prometheus",
|
||||
"cadvisor",
|
||||
"worker",
|
||||
"frontend",
|
||||
"searcher",
|
||||
"indexed-searcher",
|
||||
"grafana",
|
||||
"jaeger",
|
||||
"otel",
|
||||
}
|
||||
|
||||
// SourcegraphStatus defines the observed state of Sourcegraph
|
||||
type SourcegraphStatus struct {
|
||||
// CurrentVersion is the version of Sourcegraph currently running.
|
||||
CurrentVersion string `json:"currentVersion"`
|
||||
|
||||
// Setup tracks the progress of the setup process.
|
||||
Setup SetupStatus `json:"setup,omitempty"`
|
||||
|
||||
// Represents the latest available observations of Sourcegraph's current state.
|
||||
Conditions []metav1.Condition `json:"conditions,omitempty"`
|
||||
}
|
||||
|
||||
// Sourcegraph is the Schema for the Sourcegraph API
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
package appliance
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed web/static
|
||||
staticFiles embed.FS
|
||||
staticFS, _ = fs.Sub(staticFiles, "web/static")
|
||||
|
||||
//go:embed web/template
|
||||
templateFS embed.FS
|
||||
)
|
||||
@ -1,43 +1,41 @@
|
||||
package appliance
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/sourcegraph/log"
|
||||
)
|
||||
|
||||
const (
|
||||
queryKeyUserMessage = "sourcegraph-appliance-user-message"
|
||||
errMsgSomethingWentWrong = "Something went wrong - please contact support."
|
||||
)
|
||||
|
||||
func (a *Appliance) redirectToErrorPage(w http.ResponseWriter, req *http.Request, userMsg string, err error, userError bool) {
|
||||
a.redirectWithError(w, req, "/appliance/error", userMsg, err, userError)
|
||||
func (a *Appliance) logError(r *http.Request, err error) {
|
||||
a.logger.Error(err.Error(), log.String("method", r.Method), log.String("uri", r.URL.RequestURI()))
|
||||
}
|
||||
|
||||
func (a *Appliance) redirectWithError(w http.ResponseWriter, req *http.Request, path, userMsg string, err error, userError bool) {
|
||||
logFn := a.logger.Error
|
||||
if userError {
|
||||
logFn = a.logger.Info
|
||||
func (a *Appliance) errorResponse(w http.ResponseWriter, r *http.Request, status int, message any) {
|
||||
resp := responseData{"error": message}
|
||||
|
||||
if err := a.writeJSON(w, status, resp, nil); err != nil {
|
||||
a.logError(r, err)
|
||||
}
|
||||
logFn("an error occurred", log.Error(err))
|
||||
req = req.Clone(req.Context())
|
||||
req.URL.Path = path
|
||||
queryValues := req.URL.Query()
|
||||
queryValues.Set(queryKeyUserMessage, userMsg)
|
||||
req.URL.RawQuery = queryValues.Encode()
|
||||
http.Redirect(w, req, req.URL.String(), http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *Appliance) errorHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if err := renderTemplate("error", w, struct {
|
||||
Msg string
|
||||
}{
|
||||
Msg: req.URL.Query().Get(queryKeyUserMessage),
|
||||
}); err != nil {
|
||||
a.handleError(w, err, "executing template")
|
||||
return
|
||||
}
|
||||
})
|
||||
func (a *Appliance) badRequestResponse(w http.ResponseWriter, r *http.Request, err error) {
|
||||
a.errorResponse(w, r, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
func (a *Appliance) serverErrorResponse(w http.ResponseWriter, r *http.Request, err error) {
|
||||
a.logError(r, err)
|
||||
a.errorResponse(w, r, http.StatusInternalServerError, "the server encountered a problem and could not process your request")
|
||||
}
|
||||
|
||||
func (a *Appliance) notFoundResponse(w http.ResponseWriter, r *http.Request) {
|
||||
a.errorResponse(w, r, http.StatusNotFound, "the requested resource could not be found")
|
||||
}
|
||||
|
||||
func (a *Appliance) methodNotAllowedResponse(w http.ResponseWriter, r *http.Request) {
|
||||
a.errorResponse(w, r, http.StatusMethodNotAllowed, fmt.Sprintf("the %s method is not supported", r.Method))
|
||||
}
|
||||
|
||||
func (a *Appliance) invalidAdminPasswordResponse(w http.ResponseWriter, r *http.Request) {
|
||||
a.errorResponse(w, r, http.StatusUnauthorized, "invalid admin password")
|
||||
}
|
||||
|
||||
@ -3,6 +3,9 @@ load("@aspect_rules_ts//ts:defs.bzl", "ts_config")
|
||||
load("@npm//:defs.bzl", "npm_link_all_packages")
|
||||
load("@npm//internal/appliance/frontend/maintenance:tsconfig-to-swcconfig/package_json.bzl", tsconfig_to_swcconfig = "bin")
|
||||
load("@npm//internal/appliance/frontend/maintenance:vite/package_json.bzl", vite_bin = "bin")
|
||||
load("@rules_pkg//:pkg.bzl", "pkg_tar")
|
||||
load("//wolfi-images:defs.bzl", "wolfi_base")
|
||||
load("@container_structure_test//:defs.bzl", "container_structure_test")
|
||||
|
||||
npm_link_all_packages(
|
||||
name = "node_modules",
|
||||
@ -19,13 +22,12 @@ RUNTIME_DEPS = [
|
||||
"src/Install.tsx",
|
||||
"src/Login.tsx",
|
||||
"src/Maintenance.tsx",
|
||||
"src/OperatorDebugBar.tsx",
|
||||
"src/OperatorStatus.tsx",
|
||||
"src/Progress.tsx",
|
||||
"src/Theme.tsx",
|
||||
"src/WaitForAdmin.tsx",
|
||||
"src/api.ts",
|
||||
"src/debugBar.ts",
|
||||
"src/state.ts",
|
||||
"src/index.css",
|
||||
"src/main.tsx",
|
||||
"src/reportWebVitals.ts",
|
||||
@ -104,6 +106,7 @@ js_run_binary(
|
||||
mnemonic = "ViteBuild",
|
||||
out_dirs = ["dist"],
|
||||
tool = ":vite",
|
||||
visibility = ["//docker-images/appliance-frontend:__pkg__"],
|
||||
)
|
||||
|
||||
# Hosts the production-bundled application in a web server
|
||||
@ -113,3 +116,38 @@ vite_bin.vite_binary(
|
||||
chdir = package_name(),
|
||||
data = [":build"],
|
||||
)
|
||||
|
||||
pkg_tar(
|
||||
name = "tar_frontend",
|
||||
srcs = [":build"],
|
||||
package_dir = "maintenance",
|
||||
strip_prefix = "dist",
|
||||
visibility = ["//docker-images/appliance-frontend:__pkg__"],
|
||||
)
|
||||
|
||||
container_structure_test(
|
||||
name = "image_test",
|
||||
timeout = "short",
|
||||
configs = ["image_test.yaml"],
|
||||
driver = "docker",
|
||||
image = "//docker-images/appliance-frontend:image",
|
||||
tags = [
|
||||
"exclusive",
|
||||
"requires-network",
|
||||
TAG_INFRA_DEVINFRA,
|
||||
],
|
||||
)
|
||||
|
||||
filegroup(
|
||||
name = "config",
|
||||
srcs = ["maintenance.conf.template"],
|
||||
)
|
||||
|
||||
pkg_tar(
|
||||
name = "tar_config",
|
||||
srcs = [":config"],
|
||||
package_dir = "/etc/nginx/templates",
|
||||
visibility = ["//docker-images/appliance-frontend:__pkg__"],
|
||||
)
|
||||
|
||||
wolfi_base(target = "appliance-frontend")
|
||||
|
||||
@ -12,3 +12,17 @@ This will run the service locally, starting a Vite developer environment:
|
||||
|
||||
pnpm install
|
||||
pnpm run dev
|
||||
|
||||
## Wolfi image
|
||||
|
||||
This will build and test the Wolfi image:
|
||||
|
||||
### Building
|
||||
|
||||
bazel build //docker-images/appliance-frontend:image
|
||||
|
||||
### Testing
|
||||
|
||||
bazel test \
|
||||
//internal/appliance/frontend/maintenance:image_test \
|
||||
//docker-images/appliance-frontend:image_test
|
||||
|
||||
12
internal/appliance/frontend/maintenance/image_test.yaml
Executable file
12
internal/appliance/frontend/maintenance/image_test.yaml
Executable file
@ -0,0 +1,12 @@
|
||||
schemaVersion: "2.0.0"
|
||||
|
||||
commandTests:
|
||||
- name: maintenance server available
|
||||
command: /init.sh
|
||||
args:
|
||||
- stat
|
||||
- /etc/nginx/conf.d/maintenance.conf
|
||||
- name: maintenance app is available
|
||||
command: stat
|
||||
args:
|
||||
- /maintenance/index.html
|
||||
49
internal/appliance/frontend/maintenance/maintenance.conf.template
Executable file
49
internal/appliance/frontend/maintenance/maintenance.conf.template
Executable file
@ -0,0 +1,49 @@
|
||||
# ____ ___ ___ _ _ ____ _ _ ____ ____
|
||||
# |__| |__] |__] | | |__| |\ | | |___
|
||||
# | | | | |___ | | | | \| |___ |___
|
||||
#
|
||||
# _ _ ____ _ _ _ ___ ____ _ _ ____ _ _ ____ ____
|
||||
# |\/| |__| | |\ | | |___ |\ | |__| |\ | | |___
|
||||
# | | | | | | \| | |___ | \| | | | \| |___ |___
|
||||
#
|
||||
# Sourcegraph Appliance Maintenance UI
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name localhost;
|
||||
access_log off;
|
||||
|
||||
|
||||
location / {
|
||||
# Hideous char-mask to avoid nested ifs, which casue warnings in various
|
||||
# config linters. nginx doesn't support boolean operators as far as I
|
||||
# can tell.
|
||||
set $redirect_mask 0;
|
||||
if ($request_uri !~ ^/maintenance) {
|
||||
set $redirect_mask 1;
|
||||
}
|
||||
if ($request_uri !~ ^/api) {
|
||||
set $redirect_mask 1$redirect_mask;
|
||||
}
|
||||
if ($request_uri !~ ^/assets) {
|
||||
set $redirect_mask 1$redirect_mask;
|
||||
}
|
||||
if ($redirect_mask = 111) {
|
||||
return 302 $scheme://$host:$server_port/maintenance;
|
||||
}
|
||||
|
||||
root /maintenance;
|
||||
index index.html index.htm;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass ${API_ENDPOINT}/api/;
|
||||
}
|
||||
|
||||
error_page 404 /;
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /maintenance;
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
import { AppBar, Typography, useTheme } from '@mui/material'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
@ -7,12 +7,11 @@ import logo from '../assets/sourcegraph.png'
|
||||
|
||||
import { adminPassword, call } from './api'
|
||||
import { Login } from './Login'
|
||||
import { OperatorDebugBar } from './OperatorDebugBar'
|
||||
import { OperatorStatus } from './OperatorStatus'
|
||||
import { Info } from './Theme'
|
||||
|
||||
const FetchStateTimerMs = 1 * 1000
|
||||
const WaitToLoginAfterConnectMs = 1 * 1000
|
||||
const FetchStateTimerMs = 1000
|
||||
const WaitToLoginAfterConnectMs = 1000
|
||||
|
||||
export type stage = 'unknown' | 'install' | 'installing' | 'wait-for-admin' | 'upgrading' | 'maintenance' | 'refresh'
|
||||
|
||||
@ -29,7 +28,7 @@ export interface OutletContext {
|
||||
|
||||
const fetchStatus = async (lastContext: OutletContext): Promise<OutletContext> =>
|
||||
new Promise<OutletContext>(resolve => {
|
||||
call('/api/operator/v1beta1/stage')
|
||||
call('/api/v1/appliance/status')
|
||||
.then(result => {
|
||||
if (!result.ok) {
|
||||
if (result.status === 401) {
|
||||
@ -49,7 +48,7 @@ const fetchStatus = async (lastContext: OutletContext): Promise<OutletContext> =
|
||||
.then(result => {
|
||||
resolve({
|
||||
online: true,
|
||||
stage: result.stage,
|
||||
stage: result.status.status,
|
||||
onlineDate: lastContext.onlineDate ?? Date.now(),
|
||||
})
|
||||
})
|
||||
@ -102,9 +101,9 @@ export const Frame: React.FC = () => {
|
||||
<div id="frame">
|
||||
<AppBar color="secondary">
|
||||
<div className="product">
|
||||
<img id="logo" src={logo} />
|
||||
<img id="logo" src={logo} alt={'Sourcegraph logo'} />
|
||||
<Typography className={`title-${theme.palette.mode}`} variant="h6">
|
||||
Appliance
|
||||
Sourcegraph Appliance
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
@ -119,7 +118,6 @@ export const Frame: React.FC = () => {
|
||||
<Outlet context={context} />
|
||||
)}
|
||||
</div>
|
||||
<OperatorDebugBar context={context} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@ import { CircularProgress, Typography } from '@mui/material'
|
||||
|
||||
import './App.css'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import { useOutletContext } from 'react-router-dom'
|
||||
|
||||
import { OutletContext } from './Frame'
|
||||
|
||||
@ -1,86 +1,186 @@
|
||||
import { useState } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
import { Button, Checkbox, FormControl, InputLabel, MenuItem, Paper, Select, Stack, Typography } from '@mui/material'
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
RadioGroup,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Typography,
|
||||
Radio,
|
||||
FormControlLabel,
|
||||
FormGroup,
|
||||
FormLabel,
|
||||
FormHelperText,
|
||||
Box,
|
||||
TextField,
|
||||
Tab,
|
||||
Tabs,
|
||||
} from '@mui/material'
|
||||
|
||||
import search from '../assets/sourcegraph.png'
|
||||
|
||||
import { changeStage } from './debugBar'
|
||||
|
||||
interface InstallerProps {
|
||||
allowDisable: boolean
|
||||
}
|
||||
import { changeStage } from './state'
|
||||
|
||||
export const Install: React.FC = () => {
|
||||
const [version, setVersion] = useState<string>('5.3.1')
|
||||
const [installSearch, setInstallSearch] = useState<boolean>(true)
|
||||
type installState = 'select-version' | 'select-db-type'
|
||||
const [installState, setInstallState] = useState<installState>('select-version')
|
||||
|
||||
const install = () => {
|
||||
changeStage({ action: 'installing', data: version })
|
||||
const [versions, setVersions] = useState<string[]>([])
|
||||
const [selectedVersion, setSelectedVersion] = useState<string>('')
|
||||
|
||||
type dbType = 'built-in' | 'external'
|
||||
const [dbType, setDbType] = useState<dbType>('built-in')
|
||||
|
||||
type dbTab = 'pgsql' | 'codeintel' | 'codeinsights'
|
||||
const [dbTab, setDbTab] = useState<dbTab>('pgsql')
|
||||
|
||||
const handleDbTabChange = (event: React.SyntheticEvent, newValue: dbTab) => {
|
||||
setDbTab(newValue)
|
||||
}
|
||||
|
||||
const SearchInstaller: React.FC<InstallerProps> = ({ allowDisable = false }) => (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
width: '100%',
|
||||
gap: 2,
|
||||
}}
|
||||
onClick={allowDisable ? () => setInstallSearch(prevSarch => !prevSarch) : undefined}
|
||||
>
|
||||
<img src={search} />
|
||||
<Stack sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle2">
|
||||
<b>Search Suite</b>
|
||||
</Typography>
|
||||
<Typography variant="caption">
|
||||
Sourcegraph search suite: Code Search, Code Intelligence, <br />
|
||||
Batch Changes, and Own.
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Checkbox sx={{ p: 0 }} color="default" size="small" checked={installSearch} />
|
||||
</Paper>
|
||||
)
|
||||
useEffect(() => {
|
||||
const fetchVersions = async () => {
|
||||
try {
|
||||
const response = await fetch('https://releaseregistry.sourcegraph.com/v1/releases/sourcegraph', {
|
||||
headers: {
|
||||
Authorization: `Bearer token`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
mode: 'cors',
|
||||
})
|
||||
const data = await response.json()
|
||||
setVersions(data)
|
||||
if (data.length > 0) {
|
||||
const publicVersions = data
|
||||
.filter(item => item.public)
|
||||
.filter(item => !item.is_development)
|
||||
.map(item => item.version)
|
||||
setVersions(publicVersions)
|
||||
setSelectedVersion(publicVersions[0]) // Set the first version as default
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch versions:', error)
|
||||
|
||||
const allowInstall = installSearch
|
||||
// Very basic fallback for when release registry is down:
|
||||
// hardcode a particular version of Sourcegraph, which is the
|
||||
// latest at the time of writing.
|
||||
// This could be replaced with a fallback to a release registry
|
||||
// response fixture that appliance-frontend has access to on the
|
||||
// filesystem. In Kubernetes, this could be derived from a
|
||||
// ConfigMap, with the files being distributed to airgap users
|
||||
// out-of-band.
|
||||
const publicVersions = ['v5.5.2463']
|
||||
setVersions(publicVersions)
|
||||
setSelectedVersion(publicVersions[0])
|
||||
}
|
||||
}
|
||||
|
||||
fetchVersions()
|
||||
}, [])
|
||||
|
||||
const next = () => {
|
||||
if (selectedVersion === '') {
|
||||
alert('Please select a version')
|
||||
return
|
||||
}
|
||||
setInstallState('select-db-type')
|
||||
}
|
||||
|
||||
const back = () => {
|
||||
setInstallState('select-version')
|
||||
}
|
||||
|
||||
const install = () => {
|
||||
changeStage({ action: 'installing', data: selectedVersion })
|
||||
}
|
||||
|
||||
const handleDbSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setDbType(event.target.value as dbType)
|
||||
}
|
||||
|
||||
return (
|
||||
// Render a version selection box followed by a database configuration screen, then an install prompt
|
||||
<div className="install">
|
||||
<Typography variant="h5">Install Sourcegraph Appliance</Typography>
|
||||
<Typography variant="h5">Setup Sourcegraph</Typography>
|
||||
<Paper elevation={3} sx={{ p: 4 }}>
|
||||
<Stack direction="column" spacing={2} sx={{ alignItems: 'center' }}>
|
||||
<FormControl sx={{ minWidth: 200 }}>
|
||||
<InputLabel id="demo-simple-select-label">Version</InputLabel>
|
||||
<Select
|
||||
value={version}
|
||||
label="Age"
|
||||
onChange={e => setVersion(e.target.value)}
|
||||
sx={{ width: 200 }}
|
||||
>
|
||||
<MenuItem value={'5.3.1'}>5.3.1</MenuItem>
|
||||
<MenuItem value={'5.4.0'}>5.4.0 [Merge Demo Only]</MenuItem>
|
||||
<MenuItem value={'5.4.1 (beta)'}>5.4.0 (beta) [Merge Demo Only]</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Typography variant="subtitle1">Select Components To Install</Typography>
|
||||
<div className="components">
|
||||
<SearchInstaller allowDisable={false} />
|
||||
</div>
|
||||
<div className="message">
|
||||
{allowInstall ? (
|
||||
<Typography variant="caption">Press install to begin installation.</Typography>
|
||||
) : (
|
||||
<Typography variant="caption" color="error">
|
||||
Please select at least one component to install.
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="contained" sx={{ width: 200 }} onClick={install} disabled={!allowInstall}>
|
||||
Install
|
||||
</Button>
|
||||
</Stack>
|
||||
{installState === 'select-version' ? (
|
||||
<Stack direction="column" spacing={2} sx={{ alignItems: 'center' }}>
|
||||
<FormControl sx={{ minWidth: 200 }}>
|
||||
<InputLabel id="demo-simple-select-label">Version</InputLabel>
|
||||
<Select
|
||||
value={selectedVersion}
|
||||
label="Version"
|
||||
onChange={e => setSelectedVersion(e.target.value)}
|
||||
sx={{ width: 200 }}
|
||||
>
|
||||
{versions.map(version => (
|
||||
<MenuItem key={version} value={version}>
|
||||
{version}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<div className="message">
|
||||
<Typography variant="caption">Proceed to database configuration.</Typography>
|
||||
</div>
|
||||
<Button variant="contained" sx={{ width: 200 }} onClick={next}>
|
||||
Next
|
||||
</Button>
|
||||
</Stack>
|
||||
) : installState === 'select-db-type' ? (
|
||||
<Stack direction="column" spacing={2} alignItems={'center'}>
|
||||
<FormControl>
|
||||
<FormLabel>Configure Sourcegraph Databases</FormLabel>
|
||||
<FormGroup>
|
||||
<RadioGroup value={dbType} onChange={handleDbSelect} defaultValue="built-in">
|
||||
<FormControlLabel value="built-in" control={<Radio />} label="built-in DBs" />
|
||||
<FormHelperText id="my-helper-text" fontSize="small">
|
||||
Selecting built-in dbs, configures sourcegraph to use built in databases.
|
||||
Provisioned and controlled directly by appliance.{' '}
|
||||
</FormHelperText>
|
||||
<FormControlLabel
|
||||
value="external"
|
||||
control={<Radio />}
|
||||
label="External DBs (not yet supported)"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormGroup>
|
||||
</FormControl>
|
||||
{dbType === 'external' ? (
|
||||
<Box sx={{ width: '80%' }} alignContent={'center'}>
|
||||
<Box
|
||||
alignContent={'center'}
|
||||
sx={{ paddingBottom: 2.5, borderBottom: 1, borderColor: 'divider' }}
|
||||
>
|
||||
<Tabs value={dbTab} onChange={handleDbTabChange}>
|
||||
<Tab label="Pgsql" disabled />
|
||||
<Tab label="Codeintel-db" disabled />
|
||||
<Tab label="Codeinsights-db" disabled />
|
||||
</Tabs>
|
||||
</Box>
|
||||
<FormGroup>
|
||||
<Stack spacing={2}>
|
||||
<TextField disabled label="Port" defaultValue="5432" />
|
||||
<TextField disabled label="User" defaultValue="sg" />
|
||||
<TextField disabled label="Password" defaultValue="sg" />
|
||||
<TextField disabled label="Database" defaultValue="sg" />
|
||||
<TextField disabled label="SSL Mode" defaultValue="disable" />
|
||||
</Stack>
|
||||
</FormGroup>
|
||||
</Box>
|
||||
) : null}
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button variant="contained" sx={{ width: 200 }} onClick={back}>
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="contained" sx={{ width: 200 }} onClick={install}>
|
||||
Install
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Paper>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { createRef, useEffect, useState } from 'react'
|
||||
import React, { createRef, useEffect, useState } from 'react'
|
||||
|
||||
import Maintenance from '@mui/icons-material/Engineering'
|
||||
import { Box, Button, Paper, TextField, Typography } from '@mui/material'
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type React from 'react'
|
||||
import { Fragment, useEffect, useState } from 'react'
|
||||
|
||||
import Unhealthy from '@mui/icons-material/CarCrashOutlined'
|
||||
@ -6,10 +7,10 @@ import { Alert, Button, CircularProgress, Grid, Stack, Typography } from '@mui/m
|
||||
import classNames from 'classnames'
|
||||
|
||||
import { call } from './api'
|
||||
import { maintenance } from './debugBar'
|
||||
import { maintenance } from './state'
|
||||
|
||||
const MaintenanceStatusTimerMs = 1 * 1000
|
||||
const WaitToLaunchFixMs = 5 * 1000
|
||||
const MaintenanceStatusTimerMs = 1000
|
||||
const WaitToLaunchFixMs = 5000
|
||||
|
||||
type Service = {
|
||||
name: string
|
||||
@ -17,7 +18,7 @@ type Service = {
|
||||
message: string
|
||||
}
|
||||
|
||||
type Status = {
|
||||
type ServiceStatuses = {
|
||||
services: Service[]
|
||||
}
|
||||
|
||||
@ -54,14 +55,14 @@ const ShowServices: React.FC<{ services: Service[] }> = ({ services }) =>
|
||||
) : null
|
||||
|
||||
export const Maintenance: React.FC = () => {
|
||||
const [status, setStatus] = useState<Status | undefined>()
|
||||
const [serviceStatuses, setServiceStatuses] = useState<ServiceStatuses | undefined>()
|
||||
const [fixing, setFixing] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
call('/api/operator/v1beta1/maintenance/status')
|
||||
call('/api/v1/appliance/maintenance/serviceStatuses')
|
||||
.then(response => response.json())
|
||||
.then(setStatus)
|
||||
.then(serviceStatuses => setServiceStatuses(serviceStatuses))
|
||||
}, MaintenanceStatusTimerMs)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
@ -75,8 +76,8 @@ export const Maintenance: React.FC = () => {
|
||||
}
|
||||
}, [fixing])
|
||||
|
||||
const ready = status?.services.length !== undefined
|
||||
const unhealthy = status?.services?.find((s: Service) => !s.healthy)
|
||||
const ready = serviceStatuses?.services.length !== undefined
|
||||
const unhealthy = serviceStatuses?.services?.find((s: Service) => !s.healthy)
|
||||
|
||||
return (
|
||||
<div className="maintenance">
|
||||
@ -97,7 +98,7 @@ export const Maintenance: React.FC = () => {
|
||||
{ready ? (
|
||||
<>
|
||||
<Typography variant="h5">Service Status</Typography>
|
||||
<ShowServices services={status?.services ?? []} />
|
||||
<ShowServices services={serviceStatuses?.services ?? []} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
|
||||
@ -1,112 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button, Paper, Stack, Typography } from '@mui/material'
|
||||
|
||||
import { call } from './api'
|
||||
import { changeStage, maintenance } from './debugBar'
|
||||
import { ContextProps, stage } from './Frame'
|
||||
|
||||
const DebugBarTimerMs = 1 * 1000
|
||||
|
||||
export const OperatorDebugBar: React.FC<ContextProps> = ({ context }) => {
|
||||
const [waiting, setWaiting] = useState(false)
|
||||
|
||||
const setStage = (action: stage, data?: string) => changeStage({ action, data, onDone: () => setWaiting(true) })
|
||||
|
||||
const startInstall = () => setStage('install')
|
||||
const installProgress = () => setStage('installing')
|
||||
const installWaitAdmin = () => setStage('wait-for-admin')
|
||||
const upgradeProgress = () => setStage('upgrading', '5.4.0 (beta1)')
|
||||
const noState = () => setStage('unknown')
|
||||
const launchAdminUI = () => setStage('refresh')
|
||||
const failInstall = () => {
|
||||
call('/api/operator/v1beta1/fake/install/fail', {
|
||||
method: 'POST',
|
||||
}).then(() => {
|
||||
setWaiting(true)
|
||||
})
|
||||
}
|
||||
const setMaintenance = ({ healthy }: { healthy: boolean }) =>
|
||||
maintenance({ healthy, onDone: () => setWaiting(true) })
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
if (waiting) {
|
||||
setWaiting(false)
|
||||
}
|
||||
}, DebugBarTimerMs)
|
||||
return () => clearInterval(timer)
|
||||
}, [waiting])
|
||||
|
||||
const showDebugBar = localStorage.getItem('debugbar') === 'true'
|
||||
|
||||
return (
|
||||
context.online &&
|
||||
showDebugBar && (
|
||||
<Paper id="operator-debug" elevation={3} sx={{ m: 1, p: 2 }}>
|
||||
<Stack direction="column" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<Typography variant="caption">Operator Debug Controls</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Stack sx={{ alignItems: 'center', p: 1, border: '1px solid lightgray' }}>
|
||||
<Typography variant="caption">Installation</Typography>
|
||||
<Stack direction="row">
|
||||
<Stack direction="column">
|
||||
<Button disabled={waiting} onClick={startInstall}>
|
||||
Start
|
||||
</Button>
|
||||
<Button disabled={waiting} onClick={installProgress}>
|
||||
Progress...
|
||||
</Button>
|
||||
</Stack>
|
||||
<Stack direction="column">
|
||||
<Button disabled={waiting} onClick={installWaitAdmin}>
|
||||
Wait for admin
|
||||
</Button>
|
||||
<Button disabled={waiting} onClick={failInstall}>
|
||||
Crash
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack sx={{ alignItems: 'center', p: 1, border: '1px solid lightgray' }}>
|
||||
<Typography variant="caption">Maintenance</Typography>
|
||||
<Button disabled={waiting} onClick={() => setMaintenance({ healthy: false })}>
|
||||
Unhealthy
|
||||
</Button>
|
||||
<Button disabled={waiting} onClick={() => setMaintenance({ healthy: true })}>
|
||||
Healthy
|
||||
</Button>
|
||||
</Stack>
|
||||
<Stack
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
p: 1,
|
||||
border: '1px solid lightgray',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption">Reset</Typography>
|
||||
<Button disabled={waiting} onClick={noState}>
|
||||
Reset
|
||||
</Button>
|
||||
</Stack>
|
||||
<Stack
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
p: 1,
|
||||
border: '1px solid lightgray',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption">Upgrade</Typography>
|
||||
<Button disabled={waiting} onClick={upgradeProgress}>
|
||||
Start
|
||||
</Button>
|
||||
<Button disabled={waiting} onClick={launchAdminUI}>
|
||||
Finish
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
import React from 'react'
|
||||
|
||||
import styledReact from '@emotion/styled'
|
||||
import { styled } from '@mui/material'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
@ -20,7 +22,7 @@ export const OperatorStatus: React.FC<ContextProps> = ({ context }) => {
|
||||
const Status = () =>
|
||||
context.online === undefined ? (
|
||||
<div className="status connecting">connecting</div>
|
||||
) : context.online === true || context.needsLogin === true ? (
|
||||
) : context.online || context.needsLogin ? (
|
||||
<div className="status online">
|
||||
<OnlineIcon />
|
||||
</div>
|
||||
@ -32,14 +34,14 @@ export const OperatorStatus: React.FC<ContextProps> = ({ context }) => {
|
||||
|
||||
switch (context.stage) {
|
||||
case 'refresh':
|
||||
document.location = '/?cacheBust=' + Date.now()
|
||||
document.location.reload()
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="operator-status">
|
||||
Status: <Status />
|
||||
{context.online === false && <Navigate to="/" />}
|
||||
{!context.online && <Navigate to="/" />}
|
||||
{context.stage === 'unknown' && <Navigate to="/" />}
|
||||
{context.stage === 'install' && <Navigate to="/install" />}
|
||||
{context.stage === 'installing' && <Navigate to="/install/progress" />}
|
||||
|
||||
@ -92,13 +92,13 @@ export const Progress: React.FC<{
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
call('/api/operator/v1beta1/install/progress')
|
||||
call('/api/v1/appliance/install/progress')
|
||||
.then(result => result.json())
|
||||
.then(result => {
|
||||
setVersion(result.version)
|
||||
setProgress(result.progress)
|
||||
setError(result.error)
|
||||
setTasks(result.tasks)
|
||||
setVersion(result.progress.version)
|
||||
setProgress(result.progress.progress)
|
||||
setError(result.progress.error)
|
||||
setTasks(result.progress.tasks)
|
||||
})
|
||||
.catch(err => setError(err.message))
|
||||
}, 1000)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { PropsWithChildren, createContext, useContext, useMemo, useState } from 'react'
|
||||
import React, { PropsWithChildren, createContext, useContext, useMemo, useState } from 'react'
|
||||
|
||||
import { DarkModeOutlined, LightModeOutlined } from '@mui/icons-material'
|
||||
import { CssBaseline, ThemeProvider as MuiThemeProvider, PaletteMode, Theme, createTheme } from '@mui/material'
|
||||
@ -15,7 +15,7 @@ export const Context = createContext<ThemeContextProps>({
|
||||
theme: createTheme(),
|
||||
})
|
||||
|
||||
export const ThemeProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
export const ThemeProvider: React.FC<PropsWithChildren<any>> = ({ children }) => {
|
||||
const [mode, setMode] = useState<PaletteMode>((localStorage.getItem('theme') as PaletteMode) ?? 'light')
|
||||
|
||||
const theme = useMemo(() => {
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
import { Button, CircularProgress, Stack, Typography } from '@mui/material'
|
||||
|
||||
import { changeStage } from './debugBar'
|
||||
import { changeStage } from './state.ts'
|
||||
|
||||
const TestAdminUIGoodMs = 1 * 1000
|
||||
const WaitBeforeLaunchMs = 3 * 1000
|
||||
|
||||
export const WaitForAdmin: React.FC = () => {
|
||||
const [waitingForBalancer, setWaitingForBalancer] = useState<boolean>(false)
|
||||
const [launching, setLaunching] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
@ -20,24 +18,9 @@ export const WaitForAdmin: React.FC = () => {
|
||||
}
|
||||
}, [launching])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
fetch('/sign-in')
|
||||
.then(result => {
|
||||
console.log('waiting for admin ui', result)
|
||||
if (result.ok) {
|
||||
setLaunching(true)
|
||||
setWaitingForBalancer(false)
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
}, TestAdminUIGoodMs)
|
||||
return () => clearInterval(timer)
|
||||
}, [waitingForBalancer])
|
||||
|
||||
return (
|
||||
<div className="wait-for-admin">
|
||||
<Typography variant="h5">Waiting For The Admin To Return</Typography>
|
||||
<Typography variant="h4">Waiting For The Admin To Return</Typography>
|
||||
<div>
|
||||
<Typography sx={{ m: 2 }}>
|
||||
The appliance is ready. We were waiting for you to set its security before opening it up.
|
||||
@ -46,11 +29,7 @@ export const WaitForAdmin: React.FC = () => {
|
||||
Now that you're back, please press the button below to launch the Administration UI.
|
||||
</Typography>
|
||||
</div>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => setWaitingForBalancer(true)}
|
||||
disabled={launching || waitingForBalancer}
|
||||
>
|
||||
<Button variant="contained" onClick={() => setLaunching(true)} disabled={launching}>
|
||||
Launch Admin UI
|
||||
</Button>
|
||||
{launching && (
|
||||
@ -59,12 +38,6 @@ export const WaitForAdmin: React.FC = () => {
|
||||
<Typography variant="h5">Launching Admin UI... Please wait...</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
{waitingForBalancer && (
|
||||
<Stack direction="row" spacing={2}>
|
||||
<CircularProgress size={32} />
|
||||
<Typography variant="h5">Waiting for Admin UI to be ready... Please wait...</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
import { call } from './api'
|
||||
import { stage } from './Frame'
|
||||
|
||||
export const maintenance = ({ healthy, onDone }: { healthy: boolean; onDone?: () => void }): Promise<void> => {
|
||||
return call('/api/operator/v1beta1/fake/maintenance/healthy', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ healthy: healthy }),
|
||||
})
|
||||
.then(() => {
|
||||
call('/api/operator/v1beta1/fake/stage', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ stage: 'maintenance' }),
|
||||
}).then(() => {
|
||||
if (onDone !== undefined) {
|
||||
onDone()
|
||||
}
|
||||
})
|
||||
})
|
||||
.then(() => {
|
||||
if (onDone !== undefined) {
|
||||
onDone()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const changeStage = ({ action, data, onDone }: { action: stage; data?: string; onDone?: () => void }) => {
|
||||
call('/api/operator/v1beta1/fake/stage', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ stage: action, data }),
|
||||
}).then(() => {
|
||||
if (onDone) {
|
||||
onDone()
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
const reportWebVitals = onPerfEntry => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import { ReportHandler } from 'web-vitals'
|
||||
|
||||
const reportWebVitals = (onPerfEntry: ReportHandler) => {
|
||||
if (onPerfEntry) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry)
|
||||
getFID(onPerfEntry)
|
||||
|
||||
31
internal/appliance/frontend/maintenance/src/state.ts
Normal file
31
internal/appliance/frontend/maintenance/src/state.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { call } from './api'
|
||||
import { stage } from './Frame'
|
||||
|
||||
export const maintenance = async ({ healthy, onDone }: { healthy: boolean; onDone?: () => void }): Promise<void> => {
|
||||
await call('/api/operator/v1beta1/fake/maintenance/healthy', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ healthy: healthy }),
|
||||
})
|
||||
call('/v1/appliance/status', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ stage: 'maintenance' }),
|
||||
}).then(() => {
|
||||
if (onDone !== undefined) {
|
||||
onDone()
|
||||
}
|
||||
})
|
||||
if (onDone !== undefined) {
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
|
||||
export const changeStage = ({ action, data, onDone }: { action: stage; data?: string; onDone?: () => void }) => {
|
||||
call('/api/v1/appliance/status', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ state: action, data }),
|
||||
}).then(() => {
|
||||
if (onDone) {
|
||||
onDone()
|
||||
}
|
||||
})
|
||||
}
|
||||
44
internal/appliance/healthchecker/BUILD.bazel
Normal file
44
internal/appliance/healthchecker/BUILD.bazel
Normal file
@ -0,0 +1,44 @@
|
||||
load("//dev:go_defs.bzl", "go_test")
|
||||
load("@io_bazel_rules_go//go:def.bzl", "go_library")
|
||||
|
||||
go_library(
|
||||
name = "healthchecker",
|
||||
srcs = [
|
||||
"health_checker.go",
|
||||
"probe.go",
|
||||
],
|
||||
importpath = "github.com/sourcegraph/sourcegraph/internal/appliance/healthchecker",
|
||||
visibility = ["//:__subpackages__"],
|
||||
deps = [
|
||||
"//lib/errors",
|
||||
"@com_github_sourcegraph_log//:log",
|
||||
"@io_k8s_api//core/v1:core",
|
||||
"@io_k8s_apimachinery//pkg/labels",
|
||||
"@io_k8s_sigs_controller_runtime//pkg/client",
|
||||
],
|
||||
)
|
||||
|
||||
go_test(
|
||||
name = "healthchecker_test",
|
||||
srcs = ["health_checker_test.go"],
|
||||
data = [
|
||||
"//dev/tools:kubebuilder-assets",
|
||||
],
|
||||
embed = [":healthchecker"],
|
||||
env = {
|
||||
"KUBEBUILDER_ASSET_PATHS": "$(rlocationpaths //dev/tools:kubebuilder-assets)",
|
||||
},
|
||||
deps = [
|
||||
"//internal/appliance/k8senvtest",
|
||||
"//internal/k8s/resource/service",
|
||||
"@com_github_sourcegraph_log//:log",
|
||||
"@com_github_sourcegraph_log//logtest",
|
||||
"@com_github_sourcegraph_log_logr//:logr",
|
||||
"@com_github_stretchr_testify//require",
|
||||
"@io_k8s_api//core/v1:core",
|
||||
"@io_k8s_apimachinery//pkg/apis/meta/v1:meta",
|
||||
"@io_k8s_apimachinery//pkg/types",
|
||||
"@io_k8s_apimachinery//pkg/util/intstr",
|
||||
"@io_k8s_sigs_controller_runtime//pkg/client",
|
||||
],
|
||||
)
|
||||
95
internal/appliance/healthchecker/health_checker.go
Normal file
95
internal/appliance/healthchecker/health_checker.go
Normal file
@ -0,0 +1,95 @@
|
||||
package healthchecker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
|
||||
"github.com/sourcegraph/log"
|
||||
|
||||
"github.com/sourcegraph/sourcegraph/lib/errors"
|
||||
)
|
||||
|
||||
type Probe interface {
|
||||
CheckPods(ctx context.Context, labelSelector, namespace string) error
|
||||
}
|
||||
|
||||
type HealthChecker struct {
|
||||
Probe Probe
|
||||
K8sClient client.Client
|
||||
Logger log.Logger
|
||||
|
||||
ServiceName client.ObjectKey
|
||||
Interval time.Duration
|
||||
Graceperiod time.Duration
|
||||
}
|
||||
|
||||
// ManageIngressFacingService waits for the `begin` channel to close, then periodically monitors the frontend
|
||||
// service (the ingress-facing service). When there is at least one ready
|
||||
// frontend pod, it ensures that the service points at the frontend pods. When
|
||||
// there are no ready pods, it ensures that the service points to the appliance,
|
||||
// so that the admin can log in and view maintenance status.
|
||||
func (h *HealthChecker) ManageIngressFacingService(ctx context.Context, begin <-chan struct{}, labelSelector, namespace string) error {
|
||||
h.Logger.Info("waiting for signal to begin managing ingress-facing service for the appliance")
|
||||
select {
|
||||
case <-begin:
|
||||
// block
|
||||
|
||||
case <-ctx.Done():
|
||||
h.Logger.Error("context done, exiting", log.Error(ctx.Err()))
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
h.Logger.Info("will periodically check health of frontend and re-point ingress appropriately")
|
||||
|
||||
ticker := time.NewTicker(h.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Do one iteration without having to wait for the first tick
|
||||
if err := h.maybeFlipServiceOnce(ctx, labelSelector, namespace); err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := h.maybeFlipServiceOnce(ctx, labelSelector, namespace); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
case <-ctx.Done():
|
||||
h.Logger.Error("context done, exiting", log.Error(ctx.Err()))
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HealthChecker) maybeFlipServiceOnce(ctx context.Context, labelSelector, namespace string) error {
|
||||
h.Logger.Info("checking deployment health")
|
||||
if err := h.Probe.CheckPods(ctx, labelSelector, namespace); err != nil {
|
||||
h.Logger.Error("found unhealthy state, waiting for the grace period", log.Error(err), log.String("gracePeriod", h.Graceperiod.String()))
|
||||
time.Sleep(h.Graceperiod)
|
||||
if err := h.Probe.CheckPods(ctx, labelSelector, namespace); err != nil {
|
||||
h.Logger.Error("found unhealthy state, setting service selector to appliance", log.Error(err))
|
||||
return h.setServiceSelector(ctx, "sourcegraph-appliance-frontend")
|
||||
}
|
||||
}
|
||||
|
||||
h.Logger.Info("deployment healthy")
|
||||
return h.setServiceSelector(ctx, "sourcegraph-frontend")
|
||||
}
|
||||
|
||||
func (h *HealthChecker) setServiceSelector(ctx context.Context, to string) error {
|
||||
h.Logger.Info("setting service selector", log.String("to", to))
|
||||
|
||||
var svc corev1.Service
|
||||
if err := h.K8sClient.Get(ctx, h.ServiceName, &svc); err != nil {
|
||||
h.Logger.Error("getting service", log.Error(err))
|
||||
return errors.Wrap(err, "getting service")
|
||||
}
|
||||
|
||||
// no-op if the selector is unchanged
|
||||
svc.Spec.Selector["app"] = to
|
||||
return h.K8sClient.Update(ctx, &svc)
|
||||
}
|
||||
164
internal/appliance/healthchecker/health_checker_test.go
Normal file
164
internal/appliance/healthchecker/health_checker_test.go
Normal file
@ -0,0 +1,164 @@
|
||||
package healthchecker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/apimachinery/pkg/util/intstr"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
|
||||
"github.com/sourcegraph/log"
|
||||
"github.com/sourcegraph/log/logr"
|
||||
"github.com/sourcegraph/log/logtest"
|
||||
"github.com/sourcegraph/sourcegraph/internal/appliance/k8senvtest"
|
||||
"github.com/sourcegraph/sourcegraph/internal/k8s/resource/service"
|
||||
)
|
||||
|
||||
var (
|
||||
// set once, before suite runs. See TestMain
|
||||
ctx context.Context
|
||||
k8sClient client.Client
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
logger := log.Scoped("appliance-healthchecker-tests")
|
||||
k8sConfig, cleanup, err := k8senvtest.SetupEnvtest(ctx, logr.New(logger), k8senvtest.NewNoopReconciler)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() {
|
||||
if err := cleanup(); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
k8sClient, err = client.New(k8sConfig, client.Options{})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
rc := m.Run()
|
||||
|
||||
// Our earlier defer won't run after we call os.Exit() below
|
||||
if err := cleanup(); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
os.Exit(rc)
|
||||
}
|
||||
|
||||
// A bit of a lengthy scenario-style test
|
||||
func TestManageIngressFacingService(t *testing.T) {
|
||||
ns, err := k8senvtest.NewRandomNamespace("test-appliance-self-update")
|
||||
require.NoError(t, err)
|
||||
err = k8sClient.Create(ctx, ns)
|
||||
require.NoError(t, err)
|
||||
|
||||
serviceName := types.NamespacedName{Namespace: ns.GetName(), Name: "sourcegraph-frontend"}
|
||||
checker := &HealthChecker{
|
||||
Probe: &PodProbe{K8sClient: k8sClient},
|
||||
K8sClient: k8sClient,
|
||||
Logger: logtest.Scoped(t),
|
||||
|
||||
ServiceName: serviceName,
|
||||
Graceperiod: 0,
|
||||
}
|
||||
|
||||
// Simulate helm having created the service, but no frontend pods have been
|
||||
// created yet
|
||||
svc := service.NewService("sourcegraph-frontend", ns.GetName(), nil)
|
||||
svc.Spec.Ports = []corev1.ServicePort{
|
||||
{Name: "http", Port: 30080, TargetPort: intstr.FromString("http")},
|
||||
}
|
||||
svc.Spec.Selector = map[string]string{
|
||||
"app": "sourcegraph-appliance-frontend",
|
||||
}
|
||||
err = k8sClient.Create(ctx, &svc)
|
||||
require.NoError(t, err)
|
||||
runHealthCheckAndAssertSelector(t, checker, serviceName, ns.GetName(), "sourcegraph-appliance-frontend")
|
||||
|
||||
// Simulate some frontend pods existing but with no readiness conditions.
|
||||
pod1 := mkPod("pod1", ns.GetName())
|
||||
err = k8sClient.Create(ctx, pod1)
|
||||
require.NoError(t, err)
|
||||
pod2 := mkPod("pod2", ns.GetName())
|
||||
err = k8sClient.Create(ctx, pod2)
|
||||
require.NoError(t, err)
|
||||
runHealthCheckAndAssertSelector(t, checker, serviceName, ns.GetName(), "sourcegraph-appliance-frontend")
|
||||
|
||||
// Simulate one pod becoming ready to receive traffic
|
||||
pod1.Status.Conditions = []corev1.PodCondition{
|
||||
{
|
||||
Type: corev1.PodReady,
|
||||
Status: corev1.ConditionTrue,
|
||||
},
|
||||
}
|
||||
err = k8sClient.Status().Update(ctx, pod1)
|
||||
require.NoError(t, err)
|
||||
pod2.Status.Conditions = []corev1.PodCondition{
|
||||
{
|
||||
Type: corev1.PodReady,
|
||||
Status: corev1.ConditionFalse,
|
||||
},
|
||||
}
|
||||
err = k8sClient.Status().Update(ctx, pod2)
|
||||
require.NoError(t, err)
|
||||
runHealthCheckAndAssertSelector(t, checker, serviceName, ns.GetName(), "sourcegraph-frontend")
|
||||
|
||||
// test idempotency of the monitor
|
||||
runHealthCheckAndAssertSelector(t, checker, serviceName, ns.GetName(), "sourcegraph-frontend")
|
||||
|
||||
// Simulate pods becoming unready
|
||||
pod1.Status.Conditions = []corev1.PodCondition{
|
||||
{
|
||||
Type: corev1.PodReady,
|
||||
Status: corev1.ConditionFalse,
|
||||
},
|
||||
}
|
||||
err = k8sClient.Status().Update(ctx, pod1)
|
||||
require.NoError(t, err)
|
||||
runHealthCheckAndAssertSelector(t, checker, serviceName, ns.GetName(), "sourcegraph-appliance-frontend")
|
||||
}
|
||||
|
||||
func runHealthCheckAndAssertSelector(t *testing.T, checker *HealthChecker, serviceName types.NamespacedName, namespace, expectedSelectorValue string) {
|
||||
err := checker.maybeFlipServiceOnce(ctx, "app=sourcegraph-frontend", namespace)
|
||||
require.NoError(t, err)
|
||||
|
||||
var svc corev1.Service
|
||||
err = k8sClient.Get(ctx, serviceName, &svc)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, expectedSelectorValue, svc.Spec.Selector["app"])
|
||||
}
|
||||
|
||||
func mkPod(name, namespace string) *corev1.Pod {
|
||||
ctr := corev1.Container{
|
||||
Name: "frontend",
|
||||
Image: "foo:bar",
|
||||
Command: []string{"doitnow"},
|
||||
}
|
||||
return &corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
Labels: map[string]string{"app": "sourcegraph-frontend"},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{ctr},
|
||||
},
|
||||
}
|
||||
}
|
||||
38
internal/appliance/healthchecker/probe.go
Normal file
38
internal/appliance/healthchecker/probe.go
Normal file
@ -0,0 +1,38 @@
|
||||
package healthchecker
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/labels"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
|
||||
"github.com/sourcegraph/sourcegraph/lib/errors"
|
||||
)
|
||||
|
||||
type PodProbe struct {
|
||||
K8sClient client.Client
|
||||
}
|
||||
|
||||
func (p *PodProbe) CheckPods(ctx context.Context, labelSelector, namespace string) error {
|
||||
var pods corev1.PodList
|
||||
selector, err := labels.Parse(labelSelector)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "parsing label selector")
|
||||
}
|
||||
if err := p.K8sClient.List(ctx, &pods, &client.ListOptions{LabelSelector: selector, Namespace: namespace}); err != nil {
|
||||
return errors.Wrap(err, "listing pods")
|
||||
}
|
||||
for _, pod := range pods.Items {
|
||||
for _, condition := range pod.Status.Conditions {
|
||||
if condition.Type == corev1.PodReady {
|
||||
if condition.Status == corev1.ConditionTrue {
|
||||
// Return no error if even a single pod is ready
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors.New("no pods are ready")
|
||||
}
|
||||
@ -2,150 +2,12 @@ package appliance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/life4/genesis/slices"
|
||||
passwordvalidator "github.com/wagslane/go-password-validator"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/sourcegraph/log"
|
||||
"github.com/sourcegraph/sourcegraph/internal/appliance/config"
|
||||
"github.com/sourcegraph/sourcegraph/internal/releaseregistry"
|
||||
"github.com/sourcegraph/sourcegraph/lib/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
formValueOn = "on"
|
||||
)
|
||||
|
||||
func templatePath(name string) string {
|
||||
return filepath.Join("web", "template", name+".gohtml")
|
||||
}
|
||||
|
||||
func (a *Appliance) applianceHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if ok, _ := a.shouldSetupRun(context.Background()); ok {
|
||||
http.Redirect(w, r, "/appliance/setup", http.StatusSeeOther)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func renderTemplate(name string, w io.Writer, data any) error {
|
||||
tmpl, err := template.ParseFS(templateFS, templatePath("layout"), templatePath(name))
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "rendering template: %s", name)
|
||||
}
|
||||
return tmpl.Execute(w, data)
|
||||
}
|
||||
|
||||
func (a *Appliance) getSetupHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
versions, err := a.getVersions(r.Context())
|
||||
if err != nil {
|
||||
a.handleError(w, err, "getting versions")
|
||||
return
|
||||
}
|
||||
versions, err = NMinorVersions(versions, a.latestSupportedVersion, 2)
|
||||
if err != nil {
|
||||
a.handleError(w, err, "filtering versions to 2 minor points")
|
||||
return
|
||||
}
|
||||
|
||||
err = renderTemplate("setup", w, struct {
|
||||
Versions []string
|
||||
}{
|
||||
Versions: versions,
|
||||
})
|
||||
if err != nil {
|
||||
a.handleError(w, err, "executing template")
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Appliance) getLoginHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if len(a.adminPasswordBcrypt) == 0 {
|
||||
msg := fmt.Sprintf(
|
||||
"You must set a password: please create a secret named '%s' with key '%s'.",
|
||||
initialPasswordSecretName,
|
||||
initialPasswordSecretPasswordKey,
|
||||
)
|
||||
a.redirectToErrorPage(w, r, msg, errors.New("no admin password set"), true)
|
||||
return
|
||||
}
|
||||
|
||||
if err := renderTemplate("landing", w, struct {
|
||||
Flash string
|
||||
}{
|
||||
Flash: r.URL.Query().Get(queryKeyUserMessage),
|
||||
}); err != nil {
|
||||
a.handleError(w, err, "executing template")
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Appliance) postLoginHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
userSuppliedPassword := r.FormValue("password")
|
||||
if err := bcrypt.CompareHashAndPassword(a.adminPasswordBcrypt, []byte(userSuppliedPassword)); err != nil {
|
||||
if err == bcrypt.ErrMismatchedHashAndPassword {
|
||||
a.redirectWithError(w, r, r.URL.Path, "Supplied password is incorrect.", err, true)
|
||||
return
|
||||
}
|
||||
|
||||
a.redirectToErrorPage(w, r, errMsgSomethingWentWrong, err, false)
|
||||
return
|
||||
}
|
||||
|
||||
if err := passwordvalidator.Validate(userSuppliedPassword, 60); err != nil {
|
||||
msg := fmt.Sprintf(
|
||||
"Please set a stronger password: delete the '%s' secret, and create a new secret named '%s' with key '%s'.",
|
||||
dataSecretName,
|
||||
initialPasswordSecretName,
|
||||
initialPasswordSecretPasswordKey,
|
||||
)
|
||||
a.redirectToErrorPage(w, r, msg, err, true)
|
||||
return
|
||||
}
|
||||
|
||||
validUntil := time.Now().Add(time.Hour).UTC()
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
jwtClaimsValidUntilKey: validUntil.Format(time.RFC3339),
|
||||
})
|
||||
tokenStr, err := token.SignedString(a.jwtSecret)
|
||||
if err != nil {
|
||||
a.handleError(w, err, errMsgSomethingWentWrong)
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: authCookieName,
|
||||
Value: tokenStr,
|
||||
Expires: validUntil,
|
||||
})
|
||||
http.Redirect(w, r, "/appliance", http.StatusFound)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Appliance) handleError(w http.ResponseWriter, err error, msg string) {
|
||||
a.logger.Error(msg, log.Error(err))
|
||||
|
||||
// TODO we should probably look twice at this and decide whether it's in
|
||||
// line with existing standards.
|
||||
// Don't leak details of internal errors to users - that's why we have
|
||||
// logging above.
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprintln(w, errMsgSomethingWentWrong)
|
||||
}
|
||||
|
||||
func (a *Appliance) getVersions(ctx context.Context) ([]string, error) {
|
||||
versions, err := a.releaseRegistryClient.ListVersions(ctx, "sourcegraph")
|
||||
if err != nil {
|
||||
@ -155,52 +17,3 @@ func (a *Appliance) getVersions(ctx context.Context) ([]string, error) {
|
||||
return version.Version, version.Public
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (a *Appliance) postSetupHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
a.logger.Error("failed to parse http form request", log.Error(err))
|
||||
// Handle err
|
||||
}
|
||||
|
||||
a.sourcegraph.Spec.RequestedVersion = r.FormValue("version")
|
||||
if r.FormValue("external_database") == formValueOn {
|
||||
a.sourcegraph.Spec.PGSQL.DatabaseConnection = &config.DatabaseConnectionSpec{
|
||||
Host: r.FormValue("pgsqlDBHost"),
|
||||
Port: r.FormValue("pgsqlDBPort"),
|
||||
User: r.FormValue("pgsqlDBUser"),
|
||||
Password: r.FormValue("pgsqlDBPassword"),
|
||||
Database: r.FormValue("pgsqlDBName"),
|
||||
}
|
||||
a.sourcegraph.Spec.CodeIntel.DatabaseConnection = &config.DatabaseConnectionSpec{
|
||||
Host: r.FormValue("codeintelDBHost"),
|
||||
Port: r.FormValue("codeintelDBPort"),
|
||||
User: r.FormValue("codeintelDBUser"),
|
||||
Password: r.FormValue("codeintelDBPassword"),
|
||||
Database: r.FormValue("codeintelDBName"),
|
||||
}
|
||||
a.sourcegraph.Spec.CodeInsights.DatabaseConnection = &config.DatabaseConnectionSpec{
|
||||
Host: r.FormValue("codeinsightsDBHost"),
|
||||
Port: r.FormValue("codeinsightsDBPort"),
|
||||
User: r.FormValue("codeinsightsDBUser"),
|
||||
Password: r.FormValue("codeinsightsDBPassword"),
|
||||
Database: r.FormValue("codeinsightsDBName"),
|
||||
}
|
||||
}
|
||||
// TODO validate user input
|
||||
|
||||
if r.FormValue("dev_mode") == formValueOn {
|
||||
a.sourcegraph.SetLocalDevMode()
|
||||
}
|
||||
|
||||
_, err = a.CreateConfigMap(r.Context(), "sourcegraph-appliance")
|
||||
if err != nil {
|
||||
a.logger.Error("failed to create configMap sourcegraph-appliance", log.Error(err))
|
||||
// Handle err
|
||||
}
|
||||
a.status = StatusInstalling
|
||||
|
||||
http.Redirect(w, r, "/appliance", http.StatusSeeOther)
|
||||
})
|
||||
}
|
||||
|
||||
213
internal/appliance/json.go
Normal file
213
internal/appliance/json.go
Normal file
@ -0,0 +1,213 @@
|
||||
package appliance
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
kerrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
"github.com/sourcegraph/log"
|
||||
|
||||
"github.com/sourcegraph/sourcegraph/internal/appliance/config"
|
||||
"github.com/sourcegraph/sourcegraph/lib/errors"
|
||||
)
|
||||
|
||||
const maxBytes = 1_048_576
|
||||
|
||||
type responseData map[string]any
|
||||
|
||||
func (a *Appliance) writeJSON(w http.ResponseWriter, status int, data responseData, headers http.Header) error {
|
||||
js, err := json.MarshalIndent(data, "", "\t")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
js = append(js, '\n')
|
||||
|
||||
for key, value := range headers {
|
||||
w.Header()[key] = value
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, err = w.Write(js)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Appliance) readJSON(w http.ResponseWriter, r *http.Request, output any) error {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytes))
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
err := decoder.Decode(output)
|
||||
if err != nil {
|
||||
var jsonMaxBytesErrorType *http.MaxBytesError
|
||||
var jsonSyntaxErrorType *json.SyntaxError
|
||||
var jsonUnmarshalErrorType *json.UnmarshalTypeError
|
||||
var jsonInvalidUnmarshalErrorType *json.InvalidUnmarshalError
|
||||
|
||||
// list of de-facto errors common to JSON APIs that we want to wrap and handle
|
||||
switch {
|
||||
case strings.HasPrefix(err.Error(), "json: unknown field"):
|
||||
return errors.Newf("request body contains unknown key")
|
||||
|
||||
case errors.Is(err, io.EOF):
|
||||
return errors.New("request body must not be empty")
|
||||
|
||||
case errors.Is(err, io.ErrUnexpectedEOF):
|
||||
return errors.New("malformed JSON contained in request body")
|
||||
|
||||
case errors.As(err, &jsonSyntaxErrorType):
|
||||
return errors.Newf("malformed JSON found at character %d", jsonSyntaxErrorType.Offset)
|
||||
|
||||
case errors.As(err, &jsonMaxBytesErrorType):
|
||||
return errors.Newf("request body larger than %d bytes", jsonMaxBytesErrorType.Limit)
|
||||
|
||||
case errors.As(err, &jsonUnmarshalErrorType):
|
||||
if jsonUnmarshalErrorType.Field != "" {
|
||||
return errors.Newf("incorrect JSON type for field %q", jsonUnmarshalErrorType.Field)
|
||||
}
|
||||
return errors.Newf("incorrect JSON type found at character %d", jsonUnmarshalErrorType.Offset)
|
||||
|
||||
case errors.As(err, &jsonInvalidUnmarshalErrorType):
|
||||
panic(err)
|
||||
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = decoder.Decode(&struct{}{})
|
||||
if !errors.Is(err, io.EOF) {
|
||||
return errors.New("request body must only contain single JSON value")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Appliance) getStatusJSONHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
data := struct {
|
||||
Status string `json:"status"`
|
||||
Data string `json:"data,omitempty"`
|
||||
}{
|
||||
Status: a.status.String(),
|
||||
Data: "",
|
||||
}
|
||||
|
||||
if err := a.writeJSON(w, http.StatusOK, responseData{"status": data}, http.Header{}); err != nil {
|
||||
a.serverErrorResponse(w, r, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Appliance) getInstallProgressJSONHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
currentTasks, progress := calculateProgress(installTasks())
|
||||
|
||||
installProgress := struct {
|
||||
Version string `json:"version"`
|
||||
Progress int `json:"progress"`
|
||||
Error string `json:"error"`
|
||||
Tasks []Task `json:"tasks"`
|
||||
}{
|
||||
Version: "",
|
||||
Progress: progress,
|
||||
Error: "",
|
||||
Tasks: currentTasks,
|
||||
}
|
||||
|
||||
ok, err := a.isSourcegraphFrontendReady(r.Context())
|
||||
if err != nil {
|
||||
a.logger.Error("failed to get sourcegraph frontend status")
|
||||
return
|
||||
}
|
||||
|
||||
if ok {
|
||||
a.status = config.StatusWaitingForAdmin
|
||||
}
|
||||
|
||||
if err := a.writeJSON(w, http.StatusOK, responseData{"progress": installProgress}, http.Header{}); err != nil {
|
||||
a.serverErrorResponse(w, r, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Appliance) getMaintenanceStatusHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
type service struct {
|
||||
Name string `json:"name"`
|
||||
Healthy bool `json:"healthy"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
services := []service{}
|
||||
for _, name := range config.SourcegraphServicesToReconcile {
|
||||
services = append(services, service{
|
||||
Name: name,
|
||||
Healthy: true,
|
||||
Message: "fake event",
|
||||
})
|
||||
}
|
||||
fmt.Println(services)
|
||||
if err := a.writeJSON(w, http.StatusOK, responseData{"services": services}, http.Header{}); err != nil {
|
||||
a.serverErrorResponse(w, r, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Appliance) postStatusJSONHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
State string `json:"state"`
|
||||
Data string `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
if err := a.readJSON(w, r, &input); err != nil {
|
||||
a.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
newStatus := config.Status(input.State)
|
||||
a.logger.Info("state transition", log.String("state", string(newStatus)))
|
||||
// trim v if v exists
|
||||
input.Data = strings.TrimPrefix(input.Data, "v")
|
||||
a.sourcegraph.Spec.RequestedVersion = input.Data
|
||||
if err := a.setStatus(r.Context(), newStatus); err != nil {
|
||||
if kerrors.IsNotFound(err) {
|
||||
a.logger.Info("no configmap found, will not set status")
|
||||
} else {
|
||||
a.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if a.noResourceRestrictions {
|
||||
a.sourcegraph.SetLocalDevMode()
|
||||
}
|
||||
|
||||
cfgMap := &corev1.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "sourcegraph-appliance",
|
||||
Namespace: a.namespace,
|
||||
},
|
||||
}
|
||||
err := a.reconcileConfigMap(r.Context(), cfgMap)
|
||||
if err != nil {
|
||||
a.serverErrorResponse(w, r, err)
|
||||
}
|
||||
|
||||
a.status = newStatus
|
||||
})
|
||||
}
|
||||
230
internal/appliance/json_test.go
Normal file
230
internal/appliance/json_test.go
Normal file
@ -0,0 +1,230 @@
|
||||
package appliance
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/sourcegraph/log"
|
||||
)
|
||||
|
||||
func TestReadJSON(t *testing.T) {
|
||||
appliance := &Appliance{
|
||||
logger: log.NoOp(),
|
||||
}
|
||||
|
||||
t.Run("ValidJSON", func(t *testing.T) {
|
||||
body := `{"key": "value"}`
|
||||
req := httptest.NewRequest("POST", "/", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var output map[string]string
|
||||
err := appliance.readJSON(w, req, &output)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if diff := cmp.Diff(map[string]string{"key": "value"}, output); diff != "" {
|
||||
t.Errorf("output mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("EmptyBody", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var output map[string]string
|
||||
err := appliance.readJSON(w, req, &output)
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected an error, got nil")
|
||||
} else if err.Error() != "request body must not be empty" {
|
||||
t.Errorf("unexpected error message: got %q, want %q", err.Error(), "request body must not be empty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MalformedJSON", func(t *testing.T) {
|
||||
body := `{"key": "value",}`
|
||||
req := httptest.NewRequest("POST", "/", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var output map[string]string
|
||||
err := appliance.readJSON(w, req, &output)
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected an error, got nil")
|
||||
} else if !strings.HasPrefix(err.Error(), "malformed JSON found at character") {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("UnknownField", func(t *testing.T) {
|
||||
body := `{"unknown_field": "value"}`
|
||||
req := httptest.NewRequest("POST", "/", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var output struct{}
|
||||
err := appliance.readJSON(w, req, &output)
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected an error, got nil")
|
||||
} else if err.Error() != "request body contains unknown key" {
|
||||
t.Errorf("unexpected error message: got %q, want %q", err.Error(), "request body contains unknown key")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IncorrectJSONType", func(t *testing.T) {
|
||||
body := `{"key": 123}`
|
||||
req := httptest.NewRequest("POST", "/", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var output struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
err := appliance.readJSON(w, req, &output)
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected an error, got nil")
|
||||
} else if err.Error() != `incorrect JSON type for field "key"` {
|
||||
t.Errorf("unexpected error message: got %q, want %q", err.Error(), `incorrect JSON type for field "key"`)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MultipleJSONValues", func(t *testing.T) {
|
||||
body := `{"key1": "value1"}{"key2": "value2"}`
|
||||
req := httptest.NewRequest("POST", "/", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var output map[string]string
|
||||
err := appliance.readJSON(w, req, &output)
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected an error, got nil")
|
||||
} else if err.Error() != "request body must only contain single JSON value" {
|
||||
t.Errorf("unexpected error message: got %q, want %q", err.Error(), "request body must only contain single JSON value")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("LargeBody", func(t *testing.T) {
|
||||
// Create a large JSON object
|
||||
largeObject := map[string]string{}
|
||||
for i := 0; i < maxBytes/10; i++ {
|
||||
key := fmt.Sprintf("key%d", i)
|
||||
largeObject[key] = strings.Repeat("a", 10)
|
||||
}
|
||||
|
||||
largeJSON, _ := json.Marshal(largeObject)
|
||||
// Ensure the JSON is larger than maxBytes
|
||||
largeJSON = append(largeJSON, []byte(`,"extra":"data"}`)...)
|
||||
|
||||
req := httptest.NewRequest("POST", "/", bytes.NewReader(largeJSON))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var output map[string]string
|
||||
err := appliance.readJSON(w, req, &output)
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected an error, got nil")
|
||||
} else if !strings.HasPrefix(err.Error(), "request body larger than") {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWriteJSON(t *testing.T) {
|
||||
appliance := &Appliance{
|
||||
logger: log.NoOp(),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
data responseData
|
||||
headers http.Header
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Simple JSON response",
|
||||
status: http.StatusOK,
|
||||
data: responseData{
|
||||
"message": "Hello, World!",
|
||||
},
|
||||
headers: nil,
|
||||
expected: "{\n\t\"message\": \"Hello, World!\"\n}\n",
|
||||
},
|
||||
{
|
||||
name: "JSON response with custom headers",
|
||||
status: http.StatusCreated,
|
||||
data: responseData{
|
||||
"id": 1,
|
||||
"name": "Test",
|
||||
},
|
||||
headers: http.Header{
|
||||
"X-Custom-Header": []string{"CustomValue"},
|
||||
},
|
||||
expected: "{\n\t\"id\": 1,\n\t\"name\": \"Test\"\n}\n",
|
||||
},
|
||||
{
|
||||
name: "Empty JSON response",
|
||||
status: http.StatusNoContent,
|
||||
data: responseData{},
|
||||
headers: nil,
|
||||
expected: "{}\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
err := appliance.writeJSON(w, tt.status, tt.data, tt.headers)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if diff := cmp.Diff(tt.status, w.Code); diff != "" {
|
||||
t.Errorf("status mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
if diff := cmp.Diff("application/json", w.Header().Get("Content-Type")); diff != "" {
|
||||
t.Errorf("Content-Type mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
if diff := cmp.Diff(tt.expected, w.Body.String()); diff != "" {
|
||||
t.Errorf("body mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
if tt.headers != nil {
|
||||
for key, value := range tt.headers {
|
||||
if diff := cmp.Diff(value, w.Header()[key]); diff != "" {
|
||||
t.Errorf("header %q mismatch (-want +got):\n%s", key, diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteJSONError(t *testing.T) {
|
||||
appliance := &Appliance{
|
||||
logger: log.NoOp(),
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
data := responseData{
|
||||
"data": make(chan int),
|
||||
}
|
||||
|
||||
err := appliance.writeJSON(w, http.StatusOK, data, nil)
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected an error, got nil")
|
||||
}
|
||||
|
||||
expectedErrSubstring := "json: unsupported type: chan int"
|
||||
if diff := cmp.Diff(true, strings.Contains(err.Error(), expectedErrSubstring)); diff != "" {
|
||||
t.Errorf("error message mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
@ -2,13 +2,18 @@ load("@io_bazel_rules_go//go:def.bzl", "go_library")
|
||||
|
||||
go_library(
|
||||
name = "k8senvtest",
|
||||
srcs = ["envtest.go"],
|
||||
srcs = [
|
||||
"envtest.go",
|
||||
"namespaces.go",
|
||||
],
|
||||
importpath = "github.com/sourcegraph/sourcegraph/internal/appliance/k8senvtest",
|
||||
visibility = ["//:__subpackages__"],
|
||||
deps = [
|
||||
"//lib/errors",
|
||||
"@com_github_go_logr_logr//:logr",
|
||||
"@io_bazel_rules_go//go/runfiles:go_default_library",
|
||||
"@io_k8s_api//core/v1:core",
|
||||
"@io_k8s_apimachinery//pkg/apis/meta/v1:meta",
|
||||
"@io_k8s_client_go//kubernetes/scheme",
|
||||
"@io_k8s_client_go//rest",
|
||||
"@io_k8s_sigs_controller_runtime//:controller-runtime",
|
||||
|
||||
17
internal/appliance/k8senvtest/README.md
Normal file
17
internal/appliance/k8senvtest/README.md
Normal file
@ -0,0 +1,17 @@
|
||||
# k8senvtest
|
||||
|
||||
A wrapper package for sigs.k8s.io/controller-runtime/pkg/envtest. Has
|
||||
compatibility with our bazel setup. Any package that makes us of this one should
|
||||
add the following to the go_test directive in its BUILD.bazel:
|
||||
|
||||
```starlark
|
||||
data = [
|
||||
"//dev/tools:kubebuilder-assets",
|
||||
],
|
||||
env = {
|
||||
"KUBEBUILDER_ASSET_PATHS": "$(rlocationpaths //dev/tools:kubebuilder-assets)",
|
||||
},
|
||||
```
|
||||
|
||||
And this should just work out of the box. See consumers of this package for
|
||||
examples on how to use it, including safe teardown.
|
||||
@ -119,3 +119,11 @@ func kubebuilderAssetPathLocalDev() (string, error) {
|
||||
}
|
||||
return strings.TrimSpace(envtestOut.String()), nil
|
||||
}
|
||||
|
||||
func NewNoopReconciler(mgr ctrl.Manager) KubernetesController {
|
||||
return noopReconicler{}
|
||||
}
|
||||
|
||||
type noopReconicler struct{}
|
||||
|
||||
func (noopReconicler) SetupWithManager(_ ctrl.Manager) error { return nil }
|
||||
|
||||
34
internal/appliance/k8senvtest/namespaces.go
Normal file
34
internal/appliance/k8senvtest/namespaces.go
Normal file
@ -0,0 +1,34 @@
|
||||
package k8senvtest
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// In order to be able to run tests in isolation, we can make use of namespaces
|
||||
// with a random suffix. We don't need to delete these, all data will be
|
||||
// desstroyed on envtest teardown.
|
||||
func NewRandomNamespace(prefix string) (*corev1.Namespace, error) {
|
||||
slug, err := randomSlug()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := fmt.Sprintf("%s-%s", prefix, slug)
|
||||
return &corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func randomSlug() (string, error) {
|
||||
buf := make([]byte, 3)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
@ -1,88 +0,0 @@
|
||||
# Operator Maintenance UI
|
||||
|
||||
## Components
|
||||
|
||||
This project contains the following components:
|
||||
|
||||
### Maintenance UI
|
||||
|
||||
A React + Material UI application that communicates with the Operator and gathers data and display status.
|
||||
|
||||
Features:
|
||||
|
||||
- Installation
|
||||
- Health & Actions
|
||||
- Upgrade
|
||||
|
||||
### Mock Operator API
|
||||
|
||||
In the [mock-api](./mock-api/) folder, a Go Server application that implements the Operator API companion to the Maintenance UI.
|
||||
|
||||
#### Mock Operator Debug Bar API
|
||||
|
||||
We also implement some test APIs to enable controlling the Mock Operator from the Maitenance UI.
|
||||
|
||||
## Running Locally (Developer Mode)
|
||||
|
||||
1. Run the go application in the `mock-api` folder:
|
||||
|
||||
```
|
||||
$ cd mock-api
|
||||
$ go run ./cmd
|
||||
```
|
||||
|
||||
2. Run the Maitenance UI:
|
||||
|
||||
```
|
||||
$ pnpm run dev
|
||||
```
|
||||
|
||||
## Building Images
|
||||
|
||||
```
|
||||
$ cd build
|
||||
$ make
|
||||
```
|
||||
|
||||
It will:
|
||||
|
||||
1. Build frontend and backend distributables
|
||||
2. Build docker images
|
||||
3. Push images to the container registry
|
||||
4. Update the Helm chart with the appropriate registry image versions
|
||||
|
||||
## Helm Chart
|
||||
|
||||
### Preparing the Helm Chart
|
||||
|
||||
No action. This step is automated by the image build step.
|
||||
|
||||
### Packaging the Helm Chart
|
||||
|
||||
TBD
|
||||
|
||||
### Installing the Helm Chart
|
||||
|
||||
1. Have a Kubernetes cluster configured and available at the command line
|
||||
2. Test you can access the cluster by running: `kubectl get pods`
|
||||
3. Install the Helm chart:
|
||||
|
||||
```
|
||||
$ helm install operator ./helm
|
||||
```
|
||||
|
||||
Installer will create the `sourcegraph` namespace
|
||||
|
||||
4. Execute the commands output by the installer to get the address of
|
||||
the maintenance UI
|
||||
|
||||
### Launching the Maintenance UI
|
||||
|
||||
Once the data provided by the install step is available,
|
||||
IP address + maintenance password, open the maintenance UI in your
|
||||
browser and follow along the wizard.
|
||||
|
||||
### Run debug console
|
||||
|
||||
Maintenance UI has a debug console that can be used to control flows in the maintenance UI,
|
||||
to enable set `debugbar: true` in your browser local storage.
|
||||
@ -13,8 +13,5 @@ go_library(
|
||||
],
|
||||
importpath = "github.com/sourcegraph/sourcegraph/internal/appliance/maintenance/backend/api",
|
||||
visibility = ["//:__subpackages__"],
|
||||
deps = [
|
||||
"//internal/appliance/maintenance/backend/operator",
|
||||
"@com_github_gorilla_mux//:mux",
|
||||
],
|
||||
deps = ["@com_github_gorilla_mux//:mux"],
|
||||
)
|
||||
|
||||
@ -3,19 +3,17 @@ package api
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/sourcegraph/sourcegraph/internal/appliance/maintenance/backend/operator"
|
||||
)
|
||||
|
||||
var installError string = ""
|
||||
var installTasks []operator.Task = createInstallTasks()
|
||||
var installTasks []Task = createInstallTasks()
|
||||
var installVersion string = ""
|
||||
|
||||
type InstallProgress struct {
|
||||
Version string `json:"version"`
|
||||
Progress int `json:"progress"`
|
||||
Error string `json:"error"`
|
||||
Tasks []operator.Task `json:"tasks"`
|
||||
Version string `json:"version"`
|
||||
Progress int `json:"progress"`
|
||||
Error string `json:"error"`
|
||||
Tasks []Task `json:"tasks"`
|
||||
}
|
||||
|
||||
func InstallProgressHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@ -8,8 +8,6 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/sourcegraph/sourcegraph/internal/appliance/maintenance/backend/operator"
|
||||
)
|
||||
|
||||
var maintenanceEndpoint = os.Getenv("MAINTENANCE_ENDPOINT")
|
||||
@ -21,6 +19,26 @@ func init() {
|
||||
}
|
||||
}
|
||||
|
||||
type status struct {
|
||||
Stage Stage `json:"stage"`
|
||||
CurrentVersion *string `json:"version"` // current version, nil if not installed
|
||||
NextVersion *string `json:"nextVersion"` // version being installed/upgraded nil if not being installed/upgraded
|
||||
Tasks []Task `json:"tasks"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
type Stage string
|
||||
|
||||
const (
|
||||
StageUnknown Stage = "unknown"
|
||||
StageIdle Stage = "idle"
|
||||
StageInstall Stage = "install"
|
||||
StageInstalling Stage = "installing"
|
||||
StageUpgrading Stage = "upgrading"
|
||||
StageWaitingForAdmin Stage = "wait-for-admin"
|
||||
StageRefresh Stage = "refresh"
|
||||
)
|
||||
|
||||
type Feature struct {
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@ -40,7 +58,7 @@ type StageResponse struct {
|
||||
|
||||
var epoch = time.Unix(0, 0)
|
||||
|
||||
var currentStage operator.Stage = operator.StageInstall
|
||||
var currentStage Stage = StageInstall
|
||||
var switchToAdminTime time.Time = epoch
|
||||
|
||||
func init() {
|
||||
@ -55,17 +73,17 @@ func StageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
switch status {
|
||||
case "installing":
|
||||
currentStage = operator.StageInstalling
|
||||
currentStage = StageInstalling
|
||||
case "ready":
|
||||
fmt.Println("ready!", switchToAdminTime, currentStage)
|
||||
if switchToAdminTime == time.Unix(0, 0) {
|
||||
if currentStage != operator.StageRefresh && currentStage != operator.StageWaitingForAdmin {
|
||||
if currentStage != StageRefresh && currentStage != StageWaitingForAdmin {
|
||||
switchToAdminTime = time.Now().Add(5 * time.Second)
|
||||
}
|
||||
} else {
|
||||
if time.Now().After(switchToAdminTime) {
|
||||
switchToAdminTime = epoch
|
||||
currentStage = operator.StageWaitingForAdmin
|
||||
currentStage = StageWaitingForAdmin
|
||||
}
|
||||
}
|
||||
case "unknown":
|
||||
@ -77,8 +95,8 @@ func StageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
switch currentStage {
|
||||
case operator.StageRefresh:
|
||||
currentStage = operator.StageUnknown
|
||||
case StageRefresh:
|
||||
currentStage = StageUnknown
|
||||
}
|
||||
|
||||
fmt.Println("Sending current stage", result)
|
||||
@ -92,12 +110,12 @@ func SetStageHandlerForTesting(w http.ResponseWriter, r *http.Request) {
|
||||
receiveJson(w, r, &request)
|
||||
|
||||
fmt.Println("Setting stage to", request.Stage)
|
||||
currentStage = operator.Stage(request.Stage)
|
||||
currentStage = Stage(request.Stage)
|
||||
|
||||
fmt.Println(installTasks)
|
||||
|
||||
switch currentStage {
|
||||
case operator.StageInstalling:
|
||||
case StageInstalling:
|
||||
installError = ""
|
||||
installTasks = createInstallTasks()
|
||||
installVersion = request.Data
|
||||
@ -107,7 +125,7 @@ func SetStageHandlerForTesting(w http.ResponseWriter, r *http.Request) {
|
||||
installError = err.Error()
|
||||
}
|
||||
}()
|
||||
case operator.StageUpgrading:
|
||||
case StageUpgrading:
|
||||
installError = ""
|
||||
installTasks = createFakeUpgradeTasks()
|
||||
installVersion = request.Data
|
||||
|
||||
@ -3,16 +3,24 @@ package api
|
||||
import (
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/sourcegraph/sourcegraph/internal/appliance/maintenance/backend/operator"
|
||||
)
|
||||
|
||||
const InstallTaskWaitForCluster = 0
|
||||
const InstallTaskSetup = 1
|
||||
const InstallTaskStart = 2
|
||||
|
||||
func createInstallTasks() []operator.Task {
|
||||
return []operator.Task{
|
||||
type Task struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Started bool `json:"started"`
|
||||
Finished bool `json:"finished"`
|
||||
Weight int `json:"weight"`
|
||||
Progress int `json:"progress"`
|
||||
LastUpdate time.Time `json:"lastUpdate"`
|
||||
}
|
||||
|
||||
func createInstallTasks() []Task {
|
||||
return []Task{
|
||||
{
|
||||
Title: "Warming up",
|
||||
Description: "Setting up basic resources",
|
||||
@ -37,8 +45,8 @@ func createInstallTasks() []operator.Task {
|
||||
}
|
||||
}
|
||||
|
||||
func createFakeUpgradeTasks() []operator.Task {
|
||||
return []operator.Task{
|
||||
func createFakeUpgradeTasks() []Task {
|
||||
return []Task{
|
||||
{
|
||||
Title: "Upgrade",
|
||||
Description: "Upgrade Sourcegraph",
|
||||
@ -56,8 +64,8 @@ func createFakeUpgradeTasks() []operator.Task {
|
||||
}
|
||||
}
|
||||
|
||||
func progressTasks(tasks []operator.Task) []operator.Task {
|
||||
var result []operator.Task
|
||||
func progressTasks(tasks []Task) []Task {
|
||||
var result []Task
|
||||
|
||||
var previousStarted bool = true
|
||||
var previousFinished bool = true
|
||||
@ -76,8 +84,8 @@ func progressTasks(tasks []operator.Task) []operator.Task {
|
||||
return result
|
||||
}
|
||||
|
||||
func calculateProgress() ([]operator.Task, int) {
|
||||
var result []operator.Task
|
||||
func calculateProgress() ([]Task, int) {
|
||||
var result []Task
|
||||
|
||||
var taskWeights int = 0
|
||||
for _, t := range installTasks {
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
|
||||
|
||||
go_library(
|
||||
name = "cmd_lib",
|
||||
srcs = ["main.go"],
|
||||
importpath = "github.com/sourcegraph/sourcegraph/internal/appliance/maintenance/backend/cmd",
|
||||
visibility = ["//visibility:private"],
|
||||
deps = ["//internal/appliance/maintenance/backend/api"],
|
||||
)
|
||||
|
||||
go_binary(
|
||||
name = "cmd",
|
||||
embed = [":cmd_lib"],
|
||||
visibility = ["//:__subpackages__"],
|
||||
)
|
||||
@ -1,13 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/sourcegraph/sourcegraph/internal/appliance/maintenance/backend/api"
|
||||
)
|
||||
|
||||
func main() {
|
||||
server := api.New()
|
||||
fmt.Println("Starting mock API server")
|
||||
server.Run()
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
load("@io_bazel_rules_go//go:def.bzl", "go_library")
|
||||
|
||||
go_library(
|
||||
name = "operator",
|
||||
srcs = [
|
||||
"manage.go",
|
||||
"task.go",
|
||||
],
|
||||
importpath = "github.com/sourcegraph/sourcegraph/internal/appliance/maintenance/backend/operator",
|
||||
visibility = ["//:__subpackages__"],
|
||||
)
|
||||
@ -1,58 +0,0 @@
|
||||
package operator
|
||||
|
||||
type K8sManager interface {
|
||||
Status() *status
|
||||
Install(version string) error
|
||||
Upgrade(version string) error
|
||||
}
|
||||
|
||||
func New() K8sManager {
|
||||
return &manager{}
|
||||
}
|
||||
|
||||
type status struct {
|
||||
Stage Stage `json:"stage"`
|
||||
CurrentVersion *string `json:"version"` // current version, nil if not installed
|
||||
NextVersion *string `json:"nextVersion"` // version being installed/upgraded nil if not being installed/upgraded
|
||||
Tasks []Task `json:"tasks"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
type Stage string
|
||||
|
||||
const (
|
||||
StageUnknown Stage = "unknown"
|
||||
StageIdle Stage = "idle"
|
||||
StageInstall Stage = "install"
|
||||
StageInstalling Stage = "installing"
|
||||
StageUpgrading Stage = "upgrading"
|
||||
StageWaitingForAdmin Stage = "wait-for-admin"
|
||||
StageRefresh Stage = "refresh"
|
||||
)
|
||||
|
||||
type manager struct{}
|
||||
|
||||
// Asks the Operator to kick off a new installation of the specified version.
|
||||
//
|
||||
// Returns an error if the installation was not successful,
|
||||
// if the version is not supported, or a version is already installed.
|
||||
//
|
||||
// Once the request is accepted, the status can be tracked via the Status() method.
|
||||
func (*manager) Install(version string) error {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
// Asks the Operator to upgrade to the specified version.
|
||||
//
|
||||
// Returns an error if the upgrade was not successful,
|
||||
// if the version is not supported, or if there's no existing version installed.
|
||||
//
|
||||
// Once the request is accepted, the status can be tracked via the Status() method.
|
||||
func (*manager) Upgrade(version string) error {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
// Returns the current status of the Operator.
|
||||
func (*manager) Status() *status {
|
||||
panic("unimplemented")
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
package operator
|
||||
|
||||
import "time"
|
||||
|
||||
type Task struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Started bool `json:"started"`
|
||||
Finished bool `json:"finished"`
|
||||
Weight int `json:"weight"`
|
||||
Progress int `json:"progress"`
|
||||
LastUpdate time.Time `json:"lastUpdate"`
|
||||
}
|
||||
@ -1,151 +0,0 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
HTML,
|
||||
BODY {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
BODY {
|
||||
font-family: "Lucida Sans", "Lucida Sans Regular", "Lucida Grande",
|
||||
"Lucida Sans Unicode", Geneva, Verdana, sans-serif;
|
||||
}
|
||||
HEADER {
|
||||
display: flex;
|
||||
padding: 0.5rem 1rem;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background-color: #666666;
|
||||
}
|
||||
IMG {
|
||||
height: 64px;
|
||||
}
|
||||
H1 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: white;
|
||||
font-size: 3em;
|
||||
}
|
||||
H2 {
|
||||
border-top: 1px solid gray;
|
||||
border-left: 1px solid gray;
|
||||
padding: 0.5rem 1rem;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
.content {
|
||||
padding: 1rem;
|
||||
}
|
||||
PRE {
|
||||
background-color: #dddddd;
|
||||
padding: 1rem;
|
||||
display: inline-block;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.small {
|
||||
background-color: #dddddd;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
P {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
LI {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<img src="https://sourcegraph.com/sourcegraph-reverse-logo.svg" />
|
||||
<h1>Appliance</h1>
|
||||
</header>
|
||||
<div class="content">
|
||||
<h2>Pre-Requisites</h2>
|
||||
<ol>
|
||||
<li>A Kubernetes Cluster (any kind: k3s, minicube, GKE, EKS, etc)</li>
|
||||
<li>
|
||||
<pre class="small">kubectl</pre>
|
||||
configured in your command line with credentials to the cluster
|
||||
</li>
|
||||
<li>
|
||||
Kubernetes context set to the namespace you want to create
|
||||
Sourcegraph.
|
||||
<p>
|
||||
If you don't ever set, it will install in the
|
||||
<span class="small">default</span> namespace
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
<h2>Install</h2>
|
||||
<p>
|
||||
This is the only cluster piece required. From this point on, all
|
||||
installation happens guided by the Operator:
|
||||
</p>
|
||||
<pre>
|
||||
kubectl apply -f https://storage.googleapis.com/merge-appliance-demo/v0.0.5999925/bundle.yaml</pre
|
||||
>
|
||||
<p>
|
||||
We will need to get the IP address of the Appliance, as well the
|
||||
maintenance password.
|
||||
</p>
|
||||
<p>The steps below help you get those values...</p>
|
||||
|
||||
<h2>Get Frontend Address</h2>
|
||||
<pre>kubectl get svc operator-ui --watch</pre>
|
||||
<p>Once the external IP address is available, you visit that page.</p>
|
||||
|
||||
<pre>
|
||||
% kubectl get svc operator-ui
|
||||
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
|
||||
operator-ui LoadBalancer 10.92.6.197 34.71.130.103 80:31883/TCP 10h
|
||||
⇑⇑⇑⇑⇑⇑⇑⇑⇑⇑⇑⇑⇑
|
||||
this address</pre
|
||||
>
|
||||
|
||||
<h2>Navigate to the Appliance Page</h2>
|
||||
<pre>http://<ip-address-above>/</pre>
|
||||
|
||||
<h2>Get the Maintenance Password</h2>
|
||||
<pre>
|
||||
kubectl get secret operator-api -o json \
|
||||
| jq '{name: .metadata.name,data: .data|map_values(@base64d)}'</pre
|
||||
>
|
||||
|
||||
<p>Example output:</p>
|
||||
|
||||
<pre>
|
||||
{
|
||||
"name": "operator-api",
|
||||
"data": {
|
||||
"MAINTENANCE_PASSWORD": "password-is-here"
|
||||
}
|
||||
}</pre
|
||||
>
|
||||
|
||||
<h2>Install</h2>
|
||||
<ol>
|
||||
<li>Follow the wizard</li>
|
||||
<li>
|
||||
Once the installation is complete, you will see a "Wait for Admin to
|
||||
Return"
|
||||
<p>
|
||||
This step is to avoid exposing the admin UI before creating a user,
|
||||
allowing, for example, the administrator to leave the
|
||||
installation/upgrade/maintenance going and walk away from the
|
||||
computer.
|
||||
</p>
|
||||
</li>
|
||||
<li>Press the Launch button and the Admin UI will start</li>
|
||||
</ol>
|
||||
|
||||
<h2>Teardown</h2>
|
||||
<p>This will <b>DELETE ALL DATA:</b></p>
|
||||
<pre>
|
||||
kubectl delete -f https://storage.googleapis.com/merge-appliance-demo/v0.0.5999925/bundle.yaml
|
||||
kubectl delete pvc --all</pre
|
||||
>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,23 +0,0 @@
|
||||
# Patterns to ignore when building packages.
|
||||
# This supports shell glob matching, relative path matching, and
|
||||
# negation (prefixed with !). Only one pattern per line.
|
||||
.DS_Store
|
||||
# Common VCS dirs
|
||||
.git/
|
||||
.gitignore
|
||||
.bzr/
|
||||
.bzrignore
|
||||
.hg/
|
||||
.hgignore
|
||||
.svn/
|
||||
# Common backup files
|
||||
*.swp
|
||||
*.bak
|
||||
*.tmp
|
||||
*.orig
|
||||
*~
|
||||
# Various IDEs
|
||||
.project
|
||||
.idea/
|
||||
*.tmproj
|
||||
.vscode/
|
||||
@ -1,8 +0,0 @@
|
||||
apiVersion: v2
|
||||
name: operator
|
||||
description: Operator Maintenance UI
|
||||
|
||||
type: application
|
||||
|
||||
version: v0.0.5999860
|
||||
appVersion: "v0.0.5999860"
|
||||
@ -1,51 +0,0 @@
|
||||
==============================================================
|
||||
____ ____ _ _ ____ ____ ____ ____ ____ ____ ___ _ _
|
||||
[__ | | | | |__/ | |___ | __ |__/ |__| |__] |__|
|
||||
___] |__| |__| | \ |___ |___ |__] | \ | | | | |
|
||||
|
||||
____ ___ ____ ____ ____ ___ ____ ____
|
||||
[__] |--' |=== |--< |--| | [__] |--<
|
||||
|
||||
Version: {{ .Chart.Version }}
|
||||
|
||||
--------------------------------------------------------------
|
||||
|
||||
Thanks for installing the Operator UI.
|
||||
|
||||
To check if the operator is running, try:
|
||||
|
||||
$ helm status {{ .Release.Name }}
|
||||
$ helm get all {{ .Release.Name }}
|
||||
|
||||
--------------------------------------------------------------
|
||||
|
||||
The maintenance (and installation) interface is
|
||||
available at:
|
||||
|
||||
$ echo -n 'http://' \
|
||||
&& kubectl get service operator-ui \
|
||||
-o jsonpath='{.status.loadBalancer.ingress[0].ip}' \
|
||||
--namespace {{ .Values.namespace }} && echo
|
||||
|
||||
If the result is simply `http://`, then it means the service
|
||||
is not fully provisioned yet. Either wait or monitor the
|
||||
output of this command:
|
||||
|
||||
$ kubectl get service operator-ui \
|
||||
--namespace {{ .Values.namespace }}
|
||||
|
||||
The `EXTERNAL-IP` field will be either `<pending>` or an IP
|
||||
address.
|
||||
|
||||
--------------------------------------------------------------
|
||||
|
||||
To access the interface, you will need the maintenance
|
||||
password:
|
||||
|
||||
$ echo -n 'Password: ' \
|
||||
&& kubectl get secret operator-api \
|
||||
-o jsonpath='{.data.MAINTENANCE_PASSWORD}' \
|
||||
--namespace {{ .Values.namespace }} \
|
||||
| base64 -d && echo
|
||||
|
||||
--------------------------------------------------------------
|
||||
@ -1,31 +0,0 @@
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: operator-api
|
||||
namespace: {{ .Values.namespace }}
|
||||
labels:
|
||||
app: operator-api
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: operator-api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: operator-api
|
||||
spec:
|
||||
containers:
|
||||
- name: operator-api
|
||||
image: {{ .Values.registry }}/{{ .Values.api.image }}
|
||||
ports:
|
||||
- containerPort: 80
|
||||
env:
|
||||
- - name: API_ENDPOINT
|
||||
value: 'maintenance.{{ .Values.namespace }}.svc.cluster.local'
|
||||
- name: MAINTENANCE_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: operator-api
|
||||
key: MAINTENANCE_PASSWORD
|
||||
@ -1,10 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: operator-api
|
||||
namespace: {{ .Values.namespace }}
|
||||
data:
|
||||
{{- $secretObj := (lookup "v1" "Secret" .Values.namespace "operator-api") | default dict }}
|
||||
{{- $secretData := (get $secretObj "data") | default dict }}
|
||||
{{- $secret := (get $secretData "MAINTENANCE_PASSWORD") | default (randAlphaNum 15 | b64enc) }}
|
||||
MAINTENANCE_PASSWORD: {{ $secret | quote }}
|
||||
@ -1,14 +0,0 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: operator-api
|
||||
namespace: {{ .Values.namespace }}
|
||||
spec:
|
||||
selector:
|
||||
app: operator-api
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
targetPort: 80
|
||||
type: ClusterIP
|
||||
@ -1,4 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: {{ .Values.namespace }}
|
||||
@ -1,26 +0,0 @@
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: operator-ui
|
||||
namespace: {{ .Values.namespace }}
|
||||
labels:
|
||||
app: operator-ui
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: operator-ui
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: operator-ui
|
||||
spec:
|
||||
containers:
|
||||
- name: operator-ui
|
||||
image: {{ .Values.registry }}/{{ .Values.webui.image }}
|
||||
ports:
|
||||
- containerPort: 80
|
||||
env:
|
||||
- name: API_ENDPOINT
|
||||
value: 'http://operator-api.{{ .Values.namespace }}.svc.cluster.local'
|
||||
@ -1,14 +0,0 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: operator-ui
|
||||
namespace: {{ .Values.namespace }}
|
||||
spec:
|
||||
selector:
|
||||
app: operator-ui
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
targetPort: 80
|
||||
type: LoadBalancer
|
||||
@ -1,9 +0,0 @@
|
||||
namespace: sourcegraph
|
||||
|
||||
registry: us-central1-docker.pkg.dev/sg-infra-release-merge-feb2024/merge-workshop-feb2024
|
||||
|
||||
webui:
|
||||
image: operator-ui:v0.0.5999860
|
||||
|
||||
api:
|
||||
image: operator-api:v0.0.5999860
|
||||
@ -14,6 +14,7 @@ go_library(
|
||||
"indexed_search.go",
|
||||
"jaeger.go",
|
||||
"kubernetes.go",
|
||||
"otel_agent.go",
|
||||
"pgsql.go",
|
||||
"precise_code_intel.go",
|
||||
"prometheus.go",
|
||||
@ -21,6 +22,7 @@ go_library(
|
||||
"redis.go",
|
||||
"repo_updater.go",
|
||||
"searcher.go",
|
||||
"secret_management.go",
|
||||
"symbols.go",
|
||||
"syntect.go",
|
||||
"worker.go",
|
||||
@ -54,9 +56,11 @@ go_library(
|
||||
"@io_k8s_apimachinery//pkg/runtime",
|
||||
"@io_k8s_apimachinery//pkg/types",
|
||||
"@io_k8s_apimachinery//pkg/util/intstr",
|
||||
"@io_k8s_apimachinery//pkg/util/strategicpatch",
|
||||
"@io_k8s_client_go//tools/record",
|
||||
"@io_k8s_sigs_controller_runtime//:controller-runtime",
|
||||
"@io_k8s_sigs_controller_runtime//pkg/client",
|
||||
"@io_k8s_sigs_controller_runtime//pkg/client/apiutil",
|
||||
"@io_k8s_sigs_controller_runtime//pkg/log",
|
||||
"@io_k8s_sigs_controller_runtime//pkg/predicate",
|
||||
"@io_k8s_sigs_controller_runtime//pkg/reconcile",
|
||||
@ -83,6 +87,7 @@ go_test(
|
||||
"helpers_test.go",
|
||||
"indexed_search_test.go",
|
||||
"jaeger_test.go",
|
||||
"otel_agent_test.go",
|
||||
"pgsql_test.go",
|
||||
"precise_code_intel_test.go",
|
||||
"prometheus_test.go",
|
||||
@ -107,15 +112,21 @@ go_test(
|
||||
"//internal/appliance/config",
|
||||
"//internal/appliance/k8senvtest",
|
||||
"//internal/appliance/yaml",
|
||||
"//internal/k8s/resource/ingress",
|
||||
"//lib/pointers",
|
||||
"@com_github_life4_genesis//slices",
|
||||
"@com_github_sourcegraph_log//logtest",
|
||||
"@com_github_sourcegraph_log_logr//:logr",
|
||||
"@com_github_stretchr_testify//assert",
|
||||
"@com_github_stretchr_testify//require",
|
||||
"@com_github_stretchr_testify//suite",
|
||||
"@io_k8s_api//core/v1:core",
|
||||
"@io_k8s_api//networking/v1:networking",
|
||||
"@io_k8s_api//rbac/v1:rbac",
|
||||
"@io_k8s_apimachinery//pkg/apis/meta/v1:meta",
|
||||
"@io_k8s_apimachinery//pkg/runtime",
|
||||
"@io_k8s_apimachinery//pkg/runtime/schema",
|
||||
"@io_k8s_apimachinery//pkg/util/intstr",
|
||||
"@io_k8s_client_go//kubernetes",
|
||||
"@io_k8s_client_go//rest",
|
||||
"@io_k8s_sigs_controller_runtime//:controller-runtime",
|
||||
|
||||
@ -7,9 +7,12 @@ import (
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
netv1 "k8s.io/api/networking/v1"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
kerrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/apimachinery/pkg/util/intstr"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
|
||||
"github.com/sourcegraph/sourcegraph/internal/appliance/config"
|
||||
"github.com/sourcegraph/sourcegraph/internal/k8s/resource/container"
|
||||
@ -22,12 +25,9 @@ import (
|
||||
"github.com/sourcegraph/sourcegraph/internal/k8s/resource/serviceaccount"
|
||||
"github.com/sourcegraph/sourcegraph/lib/errors"
|
||||
"github.com/sourcegraph/sourcegraph/lib/pointers"
|
||||
)
|
||||
|
||||
const (
|
||||
pgsqlSecretName = "pgsql-auth"
|
||||
codeInsightsDBSecretName = "codeinsights-db-auth"
|
||||
codeIntelDBSecretName = "codeintel-db-auth"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/util/strategicpatch"
|
||||
)
|
||||
|
||||
func (r *Reconciler) reconcileFrontend(ctx context.Context, sg *config.Sourcegraph, owner client.Object) error {
|
||||
@ -114,6 +114,26 @@ func (r *Reconciler) reconcileFrontendDeployment(ctx context.Context, sg *config
|
||||
}
|
||||
|
||||
template := pod.NewPodTemplate("sourcegraph-frontend", cfg)
|
||||
dbConnSpecs, err := r.getDBSecrets(ctx, sg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dbConnHash, err := configHash(dbConnSpecs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
template.Template.ObjectMeta.Annotations["checksum/auth"] = dbConnHash
|
||||
|
||||
redisConnSpecs, err := r.getRedisSecrets(ctx, sg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
redisConnHash, err := configHash(redisConnSpecs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
template.Template.ObjectMeta.Annotations["checksum/redis"] = redisConnHash
|
||||
|
||||
template.Template.Spec.Containers = []corev1.Container{ctr}
|
||||
template.Template.Spec.Volumes = []corev1.Volume{pod.NewVolumeEmptyDir("home-dir")}
|
||||
template.Template.Spec.ServiceAccountName = "sourcegraph-frontend"
|
||||
@ -142,27 +162,65 @@ func (r *Reconciler) reconcileFrontendDeployment(ctx context.Context, sg *config
|
||||
dep := deployment.NewDeployment("sourcegraph-frontend", sg.Namespace, sg.Spec.RequestedVersion)
|
||||
dep.Spec.Replicas = &cfg.Replicas
|
||||
dep.Spec.Strategy.RollingUpdate = &appsv1.RollingUpdateDeployment{
|
||||
MaxSurge: pointers.Ptr(intstr.FromInt(2)),
|
||||
MaxUnavailable: pointers.Ptr(intstr.FromInt(0)),
|
||||
MaxSurge: pointers.Ptr(intstr.FromInt32(2)),
|
||||
MaxUnavailable: pointers.Ptr(intstr.FromInt32(0)),
|
||||
}
|
||||
dep.Spec.Template = template.Template
|
||||
|
||||
return reconcileObject(ctx, r, cfg, &dep, &appsv1.Deployment{}, sg, owner)
|
||||
ifChanged := struct {
|
||||
config.FrontendSpec
|
||||
DBConnSpecs
|
||||
RedisConnSpecs
|
||||
}{
|
||||
FrontendSpec: cfg,
|
||||
DBConnSpecs: dbConnSpecs,
|
||||
RedisConnSpecs: redisConnSpecs,
|
||||
}
|
||||
|
||||
return reconcileObject(ctx, r, ifChanged, &dep, &appsv1.Deployment{}, sg, owner)
|
||||
}
|
||||
|
||||
func (r *Reconciler) reconcileFrontendService(ctx context.Context, sg *config.Sourcegraph, owner client.Object) error {
|
||||
name := "sourcegraph-frontend"
|
||||
cfg := sg.Spec.Frontend
|
||||
logger := log.FromContext(ctx).WithValues("kind", "from ingress creation")
|
||||
namespacedName := types.NamespacedName{Namespace: sg.Namespace, Name: name}
|
||||
existingObj := &corev1.Service{}
|
||||
if err := r.Client.Get(ctx, namespacedName, existingObj); err != nil {
|
||||
// If we don't find an object, create one from the spec
|
||||
if kerrors.IsNotFound(err) {
|
||||
svc := service.NewService(name, sg.Namespace, cfg)
|
||||
svc.Spec.Ports = []corev1.ServicePort{
|
||||
{Name: "http", Port: 30080, TargetPort: intstr.FromString("http")},
|
||||
}
|
||||
svc.Spec.Selector = map[string]string{
|
||||
"app": "sourcegraph-appliance",
|
||||
}
|
||||
|
||||
svc := service.NewService(name, sg.Namespace, cfg)
|
||||
svc.Spec.Ports = []corev1.ServicePort{
|
||||
{Name: "http", Port: 30080, TargetPort: intstr.FromString("http")},
|
||||
}
|
||||
svc.Spec.Selector = map[string]string{
|
||||
"app": name,
|
||||
config.MarkObjectForAdoption(&svc)
|
||||
return reconcileObject(ctx, r, cfg, &svc, &corev1.Service{}, sg, owner)
|
||||
}
|
||||
logger.Error(err, "unexpected error getting object")
|
||||
return err
|
||||
}
|
||||
|
||||
return reconcileObject(ctx, r, cfg, &svc, &corev1.Service{}, sg, owner)
|
||||
// If we found an object, we only want to change configmap-specified things,
|
||||
// and certain defaults such as the prometheus port.
|
||||
svcChanges := &corev1.Service{}
|
||||
svcChanges.SetAnnotations(map[string]string{
|
||||
"prometheus.io/port": "6060",
|
||||
"sourcegraph.prometheus/scrape": "true",
|
||||
})
|
||||
config.MarkObjectForAdoption(svcChanges)
|
||||
newObj, err := MergeK8sObjects(existingObj, svcChanges)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "merging objects")
|
||||
}
|
||||
newSvc, ok := newObj.(*corev1.Service)
|
||||
if !ok {
|
||||
return errors.Wrap(err, "asserting type")
|
||||
}
|
||||
return reconcileObject(ctx, r, sg.Spec.Frontend, newSvc, &corev1.Service{}, sg, owner)
|
||||
}
|
||||
|
||||
func (r *Reconciler) reconcileFrontendServiceInternal(ctx context.Context, sg *config.Sourcegraph, owner client.Object) error {
|
||||
@ -228,43 +286,75 @@ func (r *Reconciler) reconcileFrontendRoleBinding(ctx context.Context, sg *confi
|
||||
func (r *Reconciler) reconcileFrontendIngress(ctx context.Context, sg *config.Sourcegraph, owner client.Object) error {
|
||||
name := "sourcegraph-frontend"
|
||||
cfg := sg.Spec.Frontend
|
||||
ingress := ingress.NewIngress(name, sg.Namespace)
|
||||
if cfg.Ingress == nil {
|
||||
return r.ensureObjectDeleted(ctx, &ingress)
|
||||
logger := log.FromContext(ctx).WithValues("kind", "from ingress creation")
|
||||
namespacedName := types.NamespacedName{Namespace: sg.Namespace, Name: name}
|
||||
existingObj := &netv1.Ingress{}
|
||||
if err := r.Client.Get(ctx, namespacedName, existingObj); err != nil {
|
||||
// If we don't find an object, create one from the spec
|
||||
if kerrors.IsNotFound(err) {
|
||||
ingress := ingress.NewIngress(name, sg.Namespace)
|
||||
if cfg.Ingress == nil {
|
||||
return ensureObjectDeleted(ctx, r, owner, &ingress)
|
||||
}
|
||||
|
||||
ingress.SetAnnotations(cfg.Ingress.Annotations)
|
||||
|
||||
if cfg.Ingress.TLSSecret != "" {
|
||||
ingress.Spec.TLS = []netv1.IngressTLS{{
|
||||
Hosts: []string{cfg.Ingress.Host},
|
||||
SecretName: cfg.Ingress.TLSSecret,
|
||||
}}
|
||||
}
|
||||
|
||||
ingress.Spec.Rules = []netv1.IngressRule{{
|
||||
Host: cfg.Ingress.Host,
|
||||
IngressRuleValue: netv1.IngressRuleValue{
|
||||
HTTP: &netv1.HTTPIngressRuleValue{
|
||||
Paths: []netv1.HTTPIngressPath{{
|
||||
Path: "/",
|
||||
PathType: pointers.Ptr(netv1.PathTypePrefix),
|
||||
Backend: netv1.IngressBackend{
|
||||
Service: &netv1.IngressServiceBackend{
|
||||
Name: name,
|
||||
Port: netv1.ServiceBackendPort{
|
||||
Number: 30080,
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
ingress.Spec.IngressClassName = cfg.Ingress.IngressClassName
|
||||
|
||||
config.MarkObjectForAdoption(&ingress)
|
||||
return reconcileObject(ctx, r, sg.Spec.Frontend, &ingress, &netv1.Ingress{}, sg, owner)
|
||||
}
|
||||
logger.Error(err, "unexpected error getting object")
|
||||
return err
|
||||
}
|
||||
|
||||
ingress.SetAnnotations(cfg.Ingress.Annotations)
|
||||
|
||||
//Otherwise we found an object and only want to craft changes
|
||||
cfgIngress := ingress.NewIngress(name, sg.Namespace)
|
||||
cfgIngress.SetAnnotations(cfg.Ingress.Annotations)
|
||||
if cfg.Ingress.TLSSecret != "" {
|
||||
ingress.Spec.TLS = []netv1.IngressTLS{{
|
||||
cfgIngress.Spec.TLS = []netv1.IngressTLS{{
|
||||
Hosts: []string{cfg.Ingress.Host},
|
||||
SecretName: cfg.Ingress.TLSSecret,
|
||||
}}
|
||||
}
|
||||
|
||||
ingress.Spec.Rules = []netv1.IngressRule{{
|
||||
Host: cfg.Ingress.Host,
|
||||
IngressRuleValue: netv1.IngressRuleValue{
|
||||
HTTP: &netv1.HTTPIngressRuleValue{
|
||||
Paths: []netv1.HTTPIngressPath{{
|
||||
Path: "/",
|
||||
PathType: pointers.Ptr(netv1.PathTypePrefix),
|
||||
Backend: netv1.IngressBackend{
|
||||
Service: &netv1.IngressServiceBackend{
|
||||
Name: name,
|
||||
Port: netv1.ServiceBackendPort{
|
||||
Number: 30080,
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
ingress.Spec.IngressClassName = cfg.Ingress.IngressClassName
|
||||
|
||||
return reconcileObject(ctx, r, sg.Spec.Frontend, &ingress, &netv1.Ingress{}, sg, owner)
|
||||
cfgIngress.Spec.IngressClassName = cfg.Ingress.IngressClassName
|
||||
config.MarkObjectForAdoption(&cfgIngress)
|
||||
newObj, err := MergeK8sObjects(existingObj, &cfgIngress)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "merging objects")
|
||||
}
|
||||
newObjAsIngress, ok := newObj.(*netv1.Ingress)
|
||||
if !ok {
|
||||
return errors.Wrap(err, "asserting type")
|
||||
}
|
||||
return reconcileObject(ctx, r, sg.Spec.Frontend, newObjAsIngress, &netv1.Ingress{}, sg, owner)
|
||||
}
|
||||
|
||||
func frontendEnvVars(sg *config.Sourcegraph) []corev1.EnvVar {
|
||||
@ -302,3 +392,33 @@ func dbAuthVars() []corev1.EnvVar {
|
||||
container.NewEnvVarSecretKeyRef("CODEINSIGHTS_PGUSER", codeInsightsDBSecretName, "user"),
|
||||
}
|
||||
}
|
||||
|
||||
// MergeK8sObjects merges a Kubernetes object that already exists within the cluster
|
||||
// with an existing Kubernetes object definition.
|
||||
func MergeK8sObjects(existingObj client.Object, newObject client.Object) (client.Object, error) {
|
||||
// Convert existing object to unstructured
|
||||
existingUnstructured, err := runtime.DefaultUnstructuredConverter.ToUnstructured(existingObj)
|
||||
if err != nil {
|
||||
return nil, errors.Newf("failed to convert existing object to unstructured: %w", err)
|
||||
}
|
||||
|
||||
newUnstructured, err := runtime.DefaultUnstructuredConverter.ToUnstructured(newObject)
|
||||
if err != nil {
|
||||
return nil, errors.Newf("failed to convert new object to unstructured: %w", err)
|
||||
}
|
||||
|
||||
// Merge the objects using strategic merge patch
|
||||
mergedUnstructured, err := strategicpatch.StrategicMergeMapPatch(existingUnstructured, newUnstructured, existingObj)
|
||||
if err != nil {
|
||||
return nil, errors.Newf("failed to merge objects: %w", err)
|
||||
}
|
||||
|
||||
// Convert the merged unstructured object back to the original type
|
||||
mergedObj := existingObj.DeepCopyObject().(client.Object)
|
||||
err = runtime.DefaultUnstructuredConverter.FromUnstructured(mergedUnstructured, mergedObj)
|
||||
if err != nil {
|
||||
return nil, errors.Newf("failed to convert merged object from unstructured: %w", err)
|
||||
}
|
||||
|
||||
return mergedObj, nil
|
||||
}
|
||||
|
||||
@ -1,5 +1,22 @@
|
||||
package reconciler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
netv1 "k8s.io/api/networking/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/util/intstr"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
|
||||
"github.com/sourcegraph/sourcegraph/internal/appliance/k8senvtest"
|
||||
"github.com/sourcegraph/sourcegraph/internal/k8s/resource/ingress"
|
||||
"github.com/sourcegraph/sourcegraph/lib/pointers"
|
||||
)
|
||||
|
||||
func (suite *ApplianceTestSuite) TestDeployFrontend() {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
@ -16,3 +33,324 @@ func (suite *ApplianceTestSuite) TestDeployFrontend() {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *ApplianceTestSuite) TestAdoptsHelmProvisionedFrontendResources() {
|
||||
namespace, err := k8senvtest.NewRandomNamespace("test-appliance")
|
||||
suite.Require().NoError(err)
|
||||
_, err = suite.k8sClient.CoreV1().Namespaces().Create(suite.ctx, namespace, metav1.CreateOptions{})
|
||||
suite.Require().NoError(err)
|
||||
testService := corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "sourcegraph-frontend",
|
||||
Namespace: namespace.Name,
|
||||
Labels: map[string]string{
|
||||
"app": "sourcegraph-frontend",
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Ports: []corev1.ServicePort{{Name: "http", Port: 30080, TargetPort: intstr.FromString("http")}},
|
||||
Selector: map[string]string{"app": "sourcegraph-appliance"},
|
||||
},
|
||||
}
|
||||
_, err = suite.k8sClient.CoreV1().Services(namespace.Name).Create(suite.ctx, &testService, metav1.CreateOptions{})
|
||||
suite.Require().NoError(err)
|
||||
|
||||
testIngress := ingress.NewIngress("sourcegraph-frontend", namespace.Name)
|
||||
testIngress.Spec.Rules = []netv1.IngressRule{{
|
||||
Host: "an-existing-hostname.com",
|
||||
IngressRuleValue: netv1.IngressRuleValue{
|
||||
HTTP: &netv1.HTTPIngressRuleValue{
|
||||
Paths: []netv1.HTTPIngressPath{{
|
||||
Path: "/",
|
||||
PathType: pointers.Ptr(netv1.PathTypePrefix),
|
||||
Backend: netv1.IngressBackend{
|
||||
Service: &netv1.IngressServiceBackend{
|
||||
Name: "sourcegraph-frontend",
|
||||
Port: netv1.ServiceBackendPort{
|
||||
Number: 30081,
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}}
|
||||
ingressClassName := "nginx"
|
||||
testIngress.Spec.IngressClassName = &ingressClassName
|
||||
_, err = suite.k8sClient.NetworkingV1().Ingresses(namespace.Name).Create(suite.ctx, &testIngress, metav1.CreateOptions{})
|
||||
suite.Require().NoError(err)
|
||||
|
||||
cfgMap := suite.newConfigMap(namespace.GetName(), "frontend/with-ingress")
|
||||
suite.awaitReconciliation(namespace.GetName(), func() {
|
||||
_, err := suite.k8sClient.CoreV1().ConfigMaps(namespace.GetName()).Create(suite.ctx, cfgMap, metav1.CreateOptions{})
|
||||
suite.Require().NoError(err)
|
||||
})
|
||||
suite.makeGoldenAssertions(namespace.GetName(), "frontend/adopt-service")
|
||||
}
|
||||
|
||||
func (suite *ApplianceTestSuite) TestFrontendDeploymentRollsWhenPGSecretsChange() {
|
||||
for _, tc := range []struct {
|
||||
secret string
|
||||
}{
|
||||
{secret: pgsqlSecretName},
|
||||
{secret: codeInsightsDBSecretName},
|
||||
{secret: codeIntelDBSecretName},
|
||||
} {
|
||||
suite.Run(tc.secret, func() {
|
||||
// Create the frontend before the PGSQL secret exists. In general, this
|
||||
// might happen, depending on the order of the reconcile loop. If we
|
||||
// introducce concurrency to this, we'll have little control over what
|
||||
// happens first.
|
||||
namespace := suite.createConfigMapAndAwaitReconciliation("frontend/default")
|
||||
|
||||
// Create the PGSQL secret.
|
||||
secret := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: tc.secret,
|
||||
},
|
||||
StringData: map[string]string{
|
||||
"host": "example.com",
|
||||
"port": "5432",
|
||||
"user": "alice",
|
||||
"password": "letmein",
|
||||
"database": "sg",
|
||||
},
|
||||
}
|
||||
_, err := suite.k8sClient.CoreV1().Secrets(namespace).Create(suite.ctx, secret, metav1.CreateOptions{})
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// We have to make a config change to trigger the reconcile loop
|
||||
suite.awaitReconciliation(namespace, func() {
|
||||
cfgMap := suite.newConfigMap(namespace, "frontend/default")
|
||||
cfgMap.GetAnnotations()["force-reconcile"] = "1"
|
||||
_, err := suite.k8sClient.CoreV1().ConfigMaps(namespace).Update(suite.ctx, cfgMap, metav1.UpdateOptions{})
|
||||
suite.Require().NoError(err)
|
||||
})
|
||||
|
||||
suite.makeGoldenAssertions(namespace, fmt.Sprintf("frontend/after-create-%s-secret", tc.secret))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *ApplianceTestSuite) TestFrontendDeploymentRollsWhenRedisSecretsChange() {
|
||||
for _, tc := range []struct {
|
||||
secret string
|
||||
}{
|
||||
{secret: redisCacheSecretName},
|
||||
{secret: redisStoreSecretName},
|
||||
} {
|
||||
suite.Run(tc.secret, func() {
|
||||
// Create the frontend before the PGSQL secret exists. In general, this
|
||||
// might happen, depending on the order of the reconcile loop. If we
|
||||
// introducce concurrency to this, we'll have little control over what
|
||||
// happens first.
|
||||
namespace := suite.createConfigMapAndAwaitReconciliation("frontend/default")
|
||||
|
||||
// Create the PGSQL secret.
|
||||
secret := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: tc.secret,
|
||||
},
|
||||
StringData: map[string]string{
|
||||
"endpoint": "example.com",
|
||||
},
|
||||
}
|
||||
_, err := suite.k8sClient.CoreV1().Secrets(namespace).Create(suite.ctx, secret, metav1.CreateOptions{})
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// We have to make a config change to trigger the reconcile loop
|
||||
suite.awaitReconciliation(namespace, func() {
|
||||
cfgMap := suite.newConfigMap(namespace, "frontend/default")
|
||||
cfgMap.GetAnnotations()["force-reconcile"] = "1"
|
||||
_, err := suite.k8sClient.CoreV1().ConfigMaps(namespace).Update(suite.ctx, cfgMap, metav1.UpdateOptions{})
|
||||
suite.Require().NoError(err)
|
||||
})
|
||||
|
||||
suite.makeGoldenAssertions(namespace, fmt.Sprintf("frontend/after-create-%s-secret", tc.secret))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type MockObject struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ObjectMeta `json:"metadata,omitempty"`
|
||||
Data map[string]string `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func (m *MockObject) DeepCopyObject() runtime.Object {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return &MockObject{
|
||||
TypeMeta: m.TypeMeta,
|
||||
ObjectMeta: *m.ObjectMeta.DeepCopy(),
|
||||
Data: maps.Clone(m.Data),
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *ApplianceTestSuite) TestMergeK8sObjects() {
|
||||
tests := []struct {
|
||||
name string
|
||||
existingObj client.Object
|
||||
newObject client.Object
|
||||
expected client.Object
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "Successful merge",
|
||||
existingObj: &MockObject{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: "v1",
|
||||
Kind: "ConfigMap",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-config",
|
||||
},
|
||||
Data: map[string]string{
|
||||
"key1": "value1",
|
||||
},
|
||||
},
|
||||
newObject: &MockObject{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: "v1",
|
||||
Kind: "ConfigMap",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-config",
|
||||
},
|
||||
Data: map[string]string{
|
||||
"key2": "value2",
|
||||
},
|
||||
},
|
||||
expected: &MockObject{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: "v1",
|
||||
Kind: "ConfigMap",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-config",
|
||||
},
|
||||
Data: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Merge with overlapping keys",
|
||||
existingObj: &MockObject{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: "v1",
|
||||
Kind: "ConfigMap",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-config",
|
||||
},
|
||||
Data: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "old-value2",
|
||||
},
|
||||
},
|
||||
newObject: &MockObject{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: "v1",
|
||||
Kind: "ConfigMap",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-config",
|
||||
},
|
||||
Data: map[string]string{
|
||||
"key2": "new-value2",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
expected: &MockObject{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: "v1",
|
||||
Kind: "ConfigMap",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-config",
|
||||
},
|
||||
Data: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "new-value2",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Merge with empty new object",
|
||||
existingObj: &MockObject{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: "v1",
|
||||
Kind: "ConfigMap",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-config",
|
||||
},
|
||||
Data: map[string]string{
|
||||
"key1": "value1",
|
||||
},
|
||||
},
|
||||
newObject: &MockObject{},
|
||||
expected: &MockObject{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: "v1",
|
||||
Kind: "ConfigMap",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-config",
|
||||
},
|
||||
Data: map[string]string{
|
||||
"key1": "value1",
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "merges annotations",
|
||||
existingObj: &MockObject{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Annotations: map[string]string{
|
||||
"present_and_unchanged": "old1",
|
||||
"present_and_changed": "old2",
|
||||
},
|
||||
},
|
||||
},
|
||||
newObject: &MockObject{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Annotations: map[string]string{
|
||||
"present_and_changed": "new2",
|
||||
"new": "new3",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: &MockObject{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Annotations: map[string]string{
|
||||
"present_and_unchanged": "old1",
|
||||
"present_and_changed": "new2",
|
||||
"new": "new3",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
suite.Run(tt.name, func() {
|
||||
result, err := MergeK8sObjects(tt.existingObj, tt.newObject)
|
||||
fmt.Print(result)
|
||||
if tt.expectError {
|
||||
assert.Error(suite.T(), err)
|
||||
assert.Nil(suite.T(), result)
|
||||
} else {
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.Equal(suite.T(), tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -92,6 +92,16 @@ func (r *Reconciler) reconcileGitServerStatefulSet(ctx context.Context, sg *conf
|
||||
}
|
||||
|
||||
podTemplate := pod.NewPodTemplate(name, cfg)
|
||||
redisConnSpecs, err := r.getRedisSecrets(ctx, sg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
redisConnHash, err := configHash(redisConnSpecs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
podTemplate.Template.ObjectMeta.Annotations["checksum/redis"] = redisConnHash
|
||||
|
||||
podTemplate.Template.Spec.Containers = []corev1.Container{ctr}
|
||||
podTemplate.Template.Spec.ServiceAccountName = name
|
||||
podTemplate.Template.Spec.Volumes = podVolumes
|
||||
@ -105,7 +115,14 @@ func (r *Reconciler) reconcileGitServerStatefulSet(ctx context.Context, sg *conf
|
||||
sset.Spec.Template = podTemplate.Template
|
||||
sset.Spec.VolumeClaimTemplates = []corev1.PersistentVolumeClaim{pvc}
|
||||
|
||||
return reconcileObject(ctx, r, sg.Spec.GitServer, &sset, &appsv1.StatefulSet{}, sg, owner)
|
||||
ifChanged := struct {
|
||||
config.GitServerSpec
|
||||
RedisConnSpecs
|
||||
}{
|
||||
GitServerSpec: cfg,
|
||||
RedisConnSpecs: redisConnSpecs,
|
||||
}
|
||||
return reconcileObject(ctx, r, ifChanged, &sset, &appsv1.StatefulSet{}, sg, owner)
|
||||
}
|
||||
|
||||
func (r *Reconciler) reconcileGitServerService(ctx context.Context, sg *config.Sourcegraph, owner client.Object) error {
|
||||
|
||||
@ -2,8 +2,6 @@ package reconciler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@ -63,21 +61,17 @@ func (suite *ApplianceTestSuite) TearDownSuite() {
|
||||
|
||||
func (suite *ApplianceTestSuite) createConfigMapAndAwaitReconciliation(fixtureFileName string) string {
|
||||
// Create a random namespace for each test
|
||||
namespace := "test-appliance-" + suite.randomSlug()
|
||||
ns := &corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: namespace,
|
||||
},
|
||||
}
|
||||
_, err := suite.k8sClient.CoreV1().Namespaces().Create(suite.ctx, ns, metav1.CreateOptions{})
|
||||
namespace, err := k8senvtest.NewRandomNamespace("test-appliance")
|
||||
suite.Require().NoError(err)
|
||||
_, err = suite.k8sClient.CoreV1().Namespaces().Create(suite.ctx, namespace, metav1.CreateOptions{})
|
||||
suite.Require().NoError(err)
|
||||
|
||||
cfgMap := suite.newConfigMap(namespace, fixtureFileName)
|
||||
suite.awaitReconciliation(namespace, func() {
|
||||
_, err := suite.k8sClient.CoreV1().ConfigMaps(namespace).Create(suite.ctx, cfgMap, metav1.CreateOptions{})
|
||||
cfgMap := suite.newConfigMap(namespace.GetName(), fixtureFileName)
|
||||
suite.awaitReconciliation(namespace.GetName(), func() {
|
||||
_, err := suite.k8sClient.CoreV1().ConfigMaps(namespace.GetName()).Create(suite.ctx, cfgMap, metav1.CreateOptions{})
|
||||
suite.Require().NoError(err)
|
||||
})
|
||||
return namespace
|
||||
return namespace.GetName()
|
||||
}
|
||||
|
||||
func (suite *ApplianceTestSuite) updateConfigMapAndAwaitReconciliation(namespace, fixtureFileName string) {
|
||||
@ -131,10 +125,3 @@ func (suite *ApplianceTestSuite) newConfigMap(namespace, fixtureFileName string)
|
||||
Data: map[string]string{"spec": string(cfgBytes)},
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *ApplianceTestSuite) randomSlug() string {
|
||||
buf := make([]byte, 3)
|
||||
_, err := rand.Read(buf)
|
||||
suite.Require().NoError(err)
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
@ -128,7 +128,7 @@ func (r *Reconciler) reconcileIndexedSearchIndexerService(ctx context.Context, s
|
||||
"prometheus.io/port": "6072",
|
||||
"sourcegraph.prometheus/scrape": "true",
|
||||
})
|
||||
svc.Spec.Ports = []corev1.ServicePort{{Port: 6072, TargetPort: intstr.FromInt(6072)}}
|
||||
svc.Spec.Ports = []corev1.ServicePort{{Port: 6072, TargetPort: intstr.FromInt32(6072)}}
|
||||
svc.Spec.Selector = map[string]string{
|
||||
"app": "indexed-search",
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
|
||||
"github.com/sourcegraph/sourcegraph/internal/appliance/config"
|
||||
@ -28,7 +29,7 @@ func reconcileObject[T client.Object](
|
||||
sg *config.Sourcegraph, owner client.Object,
|
||||
) error {
|
||||
if cfg.IsDisabled() {
|
||||
return r.ensureObjectDeleted(ctx, obj)
|
||||
return ensureObjectDeleted(ctx, r, owner, obj)
|
||||
}
|
||||
|
||||
updateIfChanged := struct {
|
||||
@ -62,7 +63,11 @@ func createOrUpdateObject[R client.Object](
|
||||
ctx context.Context, r *Reconciler, updateIfChanged any,
|
||||
owner client.Object, obj, objKind R,
|
||||
) error {
|
||||
logger := log.FromContext(ctx).WithValues("kind", obj.GetObjectKind().GroupVersionKind(), "namespace", obj.GetNamespace(), "name", obj.GetName())
|
||||
gvk, err := apiutil.GVKForObject(obj, r.Scheme)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting GVK for object")
|
||||
}
|
||||
logger := log.FromContext(ctx).WithValues("kind", gvk.String(), "namespace", obj.GetNamespace(), "name", obj.GetName())
|
||||
namespacedName := types.NamespacedName{Namespace: obj.GetNamespace(), Name: obj.GetName()}
|
||||
|
||||
cfgHash, err := configHash(updateIfChanged)
|
||||
@ -81,7 +86,7 @@ func createOrUpdateObject[R client.Object](
|
||||
// error: "cluster-scoped resource must not have a namespace-scoped owner".
|
||||
// non-namespaced resources will therefore not be garbage-collected when the
|
||||
// ConfigMap is deleted.
|
||||
if !isNamespaced(obj) {
|
||||
if isNamespaced(obj) {
|
||||
if err := ctrl.SetControllerReference(owner, obj, r.Scheme); err != nil {
|
||||
return errors.Newf("setting controller reference: %w", err)
|
||||
}
|
||||
@ -102,6 +107,11 @@ func createOrUpdateObject[R client.Object](
|
||||
return err
|
||||
}
|
||||
|
||||
if !isControlledBy(owner, existingRes) && isNamespaced(obj) && !config.ShouldAdopt(obj) {
|
||||
logger.Info("refusing to update non-owned resource")
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfgHash != existingRes.GetAnnotations()[config.AnnotationKeyConfigHash] {
|
||||
logger.Info("Found existing object with spec that does not match desired state. Clobbering it.")
|
||||
if err := r.Client.Update(ctx, obj); err != nil {
|
||||
@ -117,18 +127,40 @@ func createOrUpdateObject[R client.Object](
|
||||
|
||||
func isNamespaced(obj client.Object) bool {
|
||||
if _, ok := obj.(*rbacv1.ClusterRole); ok {
|
||||
return true
|
||||
return false
|
||||
}
|
||||
if _, ok := obj.(*rbacv1.ClusterRoleBinding); ok {
|
||||
return true
|
||||
return false
|
||||
}
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *Reconciler) ensureObjectDeleted(ctx context.Context, obj client.Object) error {
|
||||
logger := log.FromContext(ctx).WithValues("kind", obj.GetObjectKind().GroupVersionKind(), "namespace", obj.GetNamespace(), "name", obj.GetName())
|
||||
func ensureObjectDeleted[T client.Object](ctx context.Context, r *Reconciler, owner client.Object, obj T) error {
|
||||
// We need to try to get the object first, in order to check its owner
|
||||
// references later.
|
||||
objKey := types.NamespacedName{Name: obj.GetName(), Namespace: obj.GetNamespace()}
|
||||
if err := r.Client.Get(ctx, objKey, obj); err != nil {
|
||||
if kerrors.IsNotFound(err) {
|
||||
// Object doesn't exist, we don't need to delete it
|
||||
return nil
|
||||
}
|
||||
}
|
||||
gvk, err := apiutil.GVKForObject(obj, r.Scheme)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting GVK for object")
|
||||
}
|
||||
|
||||
logger := log.FromContext(ctx).WithValues("kind", gvk.String(), "namespace", obj.GetNamespace(), "name", obj.GetName())
|
||||
|
||||
if !isControlledBy(owner, obj) && isNamespaced(obj) {
|
||||
logger.Info("refusing to delete non-owned resource")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Info("deleting resource")
|
||||
if err := r.Client.Delete(ctx, obj); err != nil {
|
||||
if kerrors.IsNotFound(err) {
|
||||
// If by chance it got deleted concurrently, no harm done.
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -138,6 +170,15 @@ func (r *Reconciler) ensureObjectDeleted(ctx context.Context, obj client.Object)
|
||||
return nil
|
||||
}
|
||||
|
||||
func isControlledBy(owner, obj client.Object) bool {
|
||||
for _, ownerRef := range obj.GetOwnerReferences() {
|
||||
if owner.GetUID() == ownerRef.UID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func configHash(configElement any) (string, error) {
|
||||
cfgBytes, err := json.Marshal(configElement)
|
||||
if err != nil {
|
||||
|
||||
102
internal/appliance/reconciler/otel_agent.go
Normal file
102
internal/appliance/reconciler/otel_agent.go
Normal file
@ -0,0 +1,102 @@
|
||||
package reconciler
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
"k8s.io/apimachinery/pkg/util/intstr"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
|
||||
"github.com/sourcegraph/sourcegraph/internal/appliance/config"
|
||||
"github.com/sourcegraph/sourcegraph/internal/k8s/resource/configmap"
|
||||
"github.com/sourcegraph/sourcegraph/internal/k8s/resource/container"
|
||||
"github.com/sourcegraph/sourcegraph/internal/k8s/resource/daemonset"
|
||||
"github.com/sourcegraph/sourcegraph/internal/k8s/resource/pod"
|
||||
"github.com/sourcegraph/sourcegraph/internal/k8s/resource/serviceaccount"
|
||||
"github.com/sourcegraph/sourcegraph/lib/errors"
|
||||
)
|
||||
|
||||
// TODO split agent from collector? Some StandardConfig features might not work
|
||||
// particularly well if not... e.g. SA annotations
|
||||
func (r *Reconciler) reconcileOtel(ctx context.Context, sg *config.Sourcegraph, owner client.Object) error {
|
||||
if err := r.reconcileOtelAgentConfigmap(ctx, sg, owner); err != nil {
|
||||
return errors.Wrap(err, "reconciling ConfigMap")
|
||||
}
|
||||
if err := r.reconcileOtelAgentServiceAccount(ctx, sg, owner); err != nil {
|
||||
return errors.Wrap(err, "reconciling ServiceAccount")
|
||||
}
|
||||
if err := r.reconcileOtelAgentDaemonset(ctx, sg, owner); err != nil {
|
||||
return errors.Wrap(err, "reconciling DaemonSet")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Reconciler) reconcileOtelAgentConfigmap(ctx context.Context, sg *config.Sourcegraph, owner client.Object) error {
|
||||
name := "otel-agent"
|
||||
cfg := sg.Spec.OtelAgent
|
||||
cm := configmap.NewConfigMap(name, sg.Namespace)
|
||||
cm.Data = map[string]string{
|
||||
"config.yaml": string(config.OtelAgentConfig),
|
||||
}
|
||||
return reconcileObject(ctx, r, cfg, &cm, &corev1.ConfigMap{}, sg, owner)
|
||||
}
|
||||
|
||||
func (r *Reconciler) reconcileOtelAgentServiceAccount(ctx context.Context, sg *config.Sourcegraph, owner client.Object) error {
|
||||
cfg := sg.Spec.OtelAgent
|
||||
sa := serviceaccount.NewServiceAccount("otel-agent", sg.Namespace, cfg)
|
||||
return reconcileObject(ctx, r, cfg, &sa, &corev1.ServiceAccount{}, sg, owner)
|
||||
}
|
||||
|
||||
func (r *Reconciler) reconcileOtelAgentDaemonset(ctx context.Context, sg *config.Sourcegraph, owner client.Object) error {
|
||||
name := "otel-agent"
|
||||
cfg := sg.Spec.OtelAgent
|
||||
|
||||
ctr := container.NewContainer(name, cfg, config.ContainerConfig{
|
||||
Image: config.GetDefaultImage(sg, "opentelemetry-collector"),
|
||||
Resources: &corev1.ResourceRequirements{
|
||||
Requests: corev1.ResourceList{
|
||||
corev1.ResourceCPU: resource.MustParse("100m"),
|
||||
corev1.ResourceMemory: resource.MustParse("100Mi"),
|
||||
},
|
||||
Limits: corev1.ResourceList{
|
||||
corev1.ResourceCPU: resource.MustParse("500m"),
|
||||
corev1.ResourceMemory: resource.MustParse("500Mi"),
|
||||
},
|
||||
},
|
||||
})
|
||||
ctr.Command = []string{"/bin/otelcol-sourcegraph", "--config=/etc/otel-agent/config.yaml"}
|
||||
|
||||
probe := &corev1.Probe{
|
||||
ProbeHandler: corev1.ProbeHandler{
|
||||
HTTPGet: &corev1.HTTPGetAction{Path: "/", Port: intstr.FromInt(13133)},
|
||||
},
|
||||
}
|
||||
ctr.ReadinessProbe = probe
|
||||
ctr.LivenessProbe = probe
|
||||
|
||||
ctr.Ports = []corev1.ContainerPort{
|
||||
{Name: "zpages", ContainerPort: 55679, HostPort: 55679},
|
||||
{Name: "otel-grpc", ContainerPort: 4317, HostPort: 4317},
|
||||
{Name: "otel-http", ContainerPort: 4318, HostPort: 4318},
|
||||
}
|
||||
|
||||
ctr.VolumeMounts = []corev1.VolumeMount{
|
||||
{Name: "config", MountPath: "/etc/otel-agent"},
|
||||
}
|
||||
|
||||
template := pod.NewPodTemplate(name, cfg)
|
||||
template.Template.Spec.Containers = []corev1.Container{ctr}
|
||||
|
||||
cfgVol := pod.NewVolumeFromConfigMap("config", name)
|
||||
cfgVol.VolumeSource.ConfigMap.Items = []corev1.KeyToPath{
|
||||
{Key: "config.yaml", Path: "config.yaml"},
|
||||
}
|
||||
template.Template.Spec.Volumes = []corev1.Volume{cfgVol}
|
||||
|
||||
ds := daemonset.New(name, sg.Namespace, sg.Spec.RequestedVersion)
|
||||
ds.Spec.Template = template.Template
|
||||
|
||||
return reconcileObject(ctx, r, cfg, &ds, &appsv1.DaemonSet{}, sg, owner)
|
||||
}
|
||||
14
internal/appliance/reconciler/otel_agent_test.go
Normal file
14
internal/appliance/reconciler/otel_agent_test.go
Normal file
@ -0,0 +1,14 @@
|
||||
package reconciler
|
||||
|
||||
func (suite *ApplianceTestSuite) TestDeployOtelAgent() {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
}{
|
||||
{name: "otel-agent/default"},
|
||||
} {
|
||||
suite.Run(tc.name, func() {
|
||||
namespace := suite.createConfigMapAndAwaitReconciliation(tc.name)
|
||||
suite.makeGoldenAssertions(namespace, tc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -104,8 +104,8 @@ func (r *Reconciler) reconcilePreciseCodeIntelDeployment(ctx context.Context, sg
|
||||
dep := deployment.NewDeployment(name, sg.Namespace, sg.Spec.RequestedVersion)
|
||||
dep.Spec.Replicas = pointers.Ptr(cfg.Replicas)
|
||||
dep.Spec.Strategy.RollingUpdate = &appsv1.RollingUpdateDeployment{
|
||||
MaxSurge: pointers.Ptr(intstr.FromInt(1)),
|
||||
MaxUnavailable: pointers.Ptr(intstr.FromInt(1)),
|
||||
MaxSurge: pointers.Ptr(intstr.FromInt32(1)),
|
||||
MaxUnavailable: pointers.Ptr(intstr.FromInt32(1)),
|
||||
}
|
||||
dep.Spec.Template = podTemplate.Template
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ package reconciler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
@ -24,12 +25,17 @@ import (
|
||||
var _ reconcile.Reconciler = &Reconciler{}
|
||||
|
||||
type Reconciler struct {
|
||||
sync.Mutex
|
||||
client.Client
|
||||
Scheme *runtime.Scheme
|
||||
Recorder record.EventRecorder
|
||||
Scheme *runtime.Scheme
|
||||
Recorder record.EventRecorder
|
||||
BeginHealthCheckLoop chan struct{}
|
||||
}
|
||||
|
||||
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||
r.Mutex.Lock()
|
||||
defer r.Mutex.Unlock()
|
||||
|
||||
reqLog := log.FromContext(ctx)
|
||||
reqLog.Info("reconciling sourcegraph appliance")
|
||||
|
||||
@ -50,6 +56,12 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
|
||||
// tests, if it isn't useful elsewhere.
|
||||
defer r.Recorder.Event(&applianceSpec, "Normal", "ReconcileFinished", "Reconcile finished.")
|
||||
|
||||
status := applianceSpec.GetAnnotations()[config.AnnotationKeyStatus]
|
||||
if r.BeginHealthCheckLoop != nil && config.IsPostInstallStatus(config.Status(status)) {
|
||||
close(r.BeginHealthCheckLoop)
|
||||
r.BeginHealthCheckLoop = nil
|
||||
}
|
||||
|
||||
// TODO place holder code until we get the configmap spec'd out and working'
|
||||
data, ok := applianceSpec.Data["spec"]
|
||||
if !ok {
|
||||
@ -127,6 +139,9 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
|
||||
if err := r.reconcileJaeger(ctx, &sourcegraph, &applianceSpec); err != nil {
|
||||
return ctrl.Result{}, errors.Newf("failed to reconcile jaeger: %w", err)
|
||||
}
|
||||
if err := r.reconcileOtel(ctx, &sourcegraph, &applianceSpec); err != nil {
|
||||
return ctrl.Result{}, errors.Newf("failed to reconcile OpenTelemetry Collector: %w", err)
|
||||
}
|
||||
|
||||
// Set the current version annotation in case migration logic depends on it.
|
||||
applianceSpec.Annotations[config.AnnotationKeyCurrentVersion] = sourcegraph.Spec.RequestedVersion
|
||||
|
||||
@ -95,13 +95,30 @@ func (r *Reconciler) reconcileRepoUpdaterDeployment(ctx context.Context, sg *con
|
||||
}
|
||||
|
||||
podTemplate := pod.NewPodTemplate(name, cfg)
|
||||
redisConnSpecs, err := r.getRedisSecrets(ctx, sg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
redisConnHash, err := configHash(redisConnSpecs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
podTemplate.Template.ObjectMeta.Annotations["checksum/redis"] = redisConnHash
|
||||
|
||||
podTemplate.Template.Spec.Containers = []corev1.Container{ctr}
|
||||
|
||||
dep := deployment.NewDeployment(name, sg.Namespace, sg.Spec.RequestedVersion)
|
||||
dep.Spec.Template = podTemplate.Template
|
||||
dep.Spec.Template.Spec.ServiceAccountName = name
|
||||
|
||||
return reconcileObject(ctx, r, sg.Spec.RepoUpdater, &dep, &appsv1.Deployment{}, sg, owner)
|
||||
ifChanged := struct {
|
||||
config.RepoUpdaterSpec
|
||||
RedisConnSpecs
|
||||
}{
|
||||
RepoUpdaterSpec: cfg,
|
||||
RedisConnSpecs: redisConnSpecs,
|
||||
}
|
||||
return reconcileObject(ctx, r, ifChanged, &dep, &appsv1.Deployment{}, sg, owner)
|
||||
}
|
||||
|
||||
func (r *Reconciler) reconcileRepoUpdaterServiceAccount(ctx context.Context, sg *config.Sourcegraph, owner client.Object) error {
|
||||
|
||||
@ -91,6 +91,16 @@ func (r *Reconciler) reconcileSearcherStatefulSet(ctx context.Context, sg *confi
|
||||
}
|
||||
|
||||
podTemplate := pod.NewPodTemplate(name, cfg)
|
||||
redisConnSpecs, err := r.getRedisSecrets(ctx, sg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
redisConnHash, err := configHash(redisConnSpecs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
podTemplate.Template.ObjectMeta.Annotations["checksum/redis"] = redisConnHash
|
||||
|
||||
podTemplate.Template.Spec.Containers = []corev1.Container{ctr}
|
||||
podTemplate.Template.Spec.Volumes = []corev1.Volume{
|
||||
{Name: "cache"},
|
||||
@ -108,7 +118,14 @@ func (r *Reconciler) reconcileSearcherStatefulSet(ctx context.Context, sg *confi
|
||||
sset.Spec.Replicas = &cfg.Replicas
|
||||
sset.Spec.VolumeClaimTemplates = []corev1.PersistentVolumeClaim{pvc}
|
||||
|
||||
return reconcileObject(ctx, r, cfg, &sset, &appsv1.StatefulSet{}, sg, owner)
|
||||
ifChanged := struct {
|
||||
config.SearcherSpec
|
||||
RedisConnSpecs
|
||||
}{
|
||||
SearcherSpec: cfg,
|
||||
RedisConnSpecs: redisConnSpecs,
|
||||
}
|
||||
return reconcileObject(ctx, r, ifChanged, &sset, &appsv1.StatefulSet{}, sg, owner)
|
||||
}
|
||||
|
||||
func (r *Reconciler) reconcileSearcherService(ctx context.Context, sg *config.Sourcegraph, owner client.Object) error {
|
||||
|
||||
122
internal/appliance/reconciler/secret_management.go
Normal file
122
internal/appliance/reconciler/secret_management.go
Normal file
@ -0,0 +1,122 @@
|
||||
package reconciler
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
kerrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
|
||||
"github.com/sourcegraph/sourcegraph/internal/appliance/config"
|
||||
"github.com/sourcegraph/sourcegraph/lib/errors"
|
||||
)
|
||||
|
||||
// Utilities to cause rolling deployments when secrets change live here.
|
||||
// Indirectly tested through service-definition-specific golden tests.
|
||||
|
||||
const (
|
||||
pgsqlSecretName = "pgsql-auth"
|
||||
codeInsightsDBSecretName = "codeinsights-db-auth"
|
||||
codeIntelDBSecretName = "codeintel-db-auth"
|
||||
redisCacheSecretName = "redis-cache"
|
||||
redisStoreSecretName = "redis-store"
|
||||
)
|
||||
|
||||
type DBConnSpecs struct {
|
||||
PG *config.DatabaseConnectionSpec `json:"pg,omitempty"`
|
||||
CodeIntel *config.DatabaseConnectionSpec `json:"codeintel,omitempty"`
|
||||
CodeInsights *config.DatabaseConnectionSpec `json:"codeinsights,omitempty"`
|
||||
}
|
||||
|
||||
type RedisConnSpecs struct {
|
||||
Cache string `json:"cache,omitempty"`
|
||||
Store string `json:"store,omitempty"`
|
||||
}
|
||||
|
||||
func (r *Reconciler) getDBSecrets(ctx context.Context, sg *config.Sourcegraph) (DBConnSpecs, error) {
|
||||
dbConnSpec, err := r.getDBSecret(ctx, sg, pgsqlSecretName)
|
||||
if err != nil {
|
||||
return DBConnSpecs{}, err
|
||||
}
|
||||
codeIntelConnSpec, err := r.getDBSecret(ctx, sg, codeIntelDBSecretName)
|
||||
if err != nil {
|
||||
return DBConnSpecs{}, err
|
||||
}
|
||||
codeInsightsConnSpec, err := r.getDBSecret(ctx, sg, codeInsightsDBSecretName)
|
||||
if err != nil {
|
||||
return DBConnSpecs{}, err
|
||||
}
|
||||
return DBConnSpecs{
|
||||
PG: dbConnSpec,
|
||||
CodeIntel: codeIntelConnSpec,
|
||||
CodeInsights: codeInsightsConnSpec,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Reconciler) getRedisSecrets(ctx context.Context, sg *config.Sourcegraph) (RedisConnSpecs, error) {
|
||||
redisCacheEndpoint, err := r.getRedisSecret(ctx, sg, redisCacheSecretName)
|
||||
if err != nil {
|
||||
return RedisConnSpecs{}, err
|
||||
}
|
||||
redisStoreEndpoint, err := r.getRedisSecret(ctx, sg, redisStoreSecretName)
|
||||
if err != nil {
|
||||
return RedisConnSpecs{}, err
|
||||
}
|
||||
return RedisConnSpecs{
|
||||
Cache: redisCacheEndpoint,
|
||||
Store: redisStoreEndpoint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Reconciler) getDBSecret(ctx context.Context, sg *config.Sourcegraph, secretName string) (*config.DatabaseConnectionSpec, error) {
|
||||
dbSecret, err := r.getSecret(ctx, sg, secretName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &config.DatabaseConnectionSpec{
|
||||
Host: string(dbSecret.Data["host"]),
|
||||
Port: string(dbSecret.Data["port"]),
|
||||
User: string(dbSecret.Data["user"]),
|
||||
Password: string(dbSecret.Data["password"]),
|
||||
Database: string(dbSecret.Data["database"]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Reconciler) getRedisSecret(ctx context.Context, sg *config.Sourcegraph, secretName string) (string, error) {
|
||||
redisSecret, err := r.getSecret(ctx, sg, secretName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(redisSecret.Data["endpoint"]), nil
|
||||
}
|
||||
|
||||
func (r *Reconciler) getSecret(ctx context.Context, sg *config.Sourcegraph, secretName string) (*corev1.Secret, error) {
|
||||
var secret corev1.Secret
|
||||
secretNsName := types.NamespacedName{Name: secretName, Namespace: sg.Namespace}
|
||||
if err := r.Client.Get(ctx, secretNsName, &secret); err != nil {
|
||||
if !kerrors.IsNotFound(err) {
|
||||
return nil, errors.Wrapf(err, "getting secret %s", secretName)
|
||||
}
|
||||
|
||||
// If we cannot find the secret, return nil but also no error. We can
|
||||
// still serialize an ifChanged object in reconcileFrontendDeployment().
|
||||
// We should do this rather than fail the reconcile loop here, because
|
||||
// Kubernetes does not have inter-service dependencies, so it is
|
||||
// idiomatic to finish the loop even if the desired global final state
|
||||
// has not been reached. The next reconciliation after the secret exists
|
||||
// will yield a different result, which will cause deployed pods to roll
|
||||
// (since the spec.template.metadata.annotations changes).
|
||||
//
|
||||
// We return a zero-valued secret to avoid nil pointer explosions. All
|
||||
// data fields will be empty. Currently, all callers only use this
|
||||
// function to hash the data to see if its changed, so this seems ok to
|
||||
// do.
|
||||
log.FromContext(ctx).Info("could not find secret", "secretName", secretName, "err", err)
|
||||
return &corev1.Secret{}, nil
|
||||
}
|
||||
|
||||
return &secret, nil
|
||||
}
|
||||
@ -1,5 +1,12 @@
|
||||
package reconciler
|
||||
|
||||
import (
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
"github.com/sourcegraph/sourcegraph/internal/appliance/k8senvtest"
|
||||
)
|
||||
|
||||
// Use this file to test features available in StandardConfig (see
|
||||
// development.md and config subpackage).
|
||||
|
||||
@ -33,3 +40,39 @@ func (suite *ApplianceTestSuite) TestResourcesDeletedWhenDisabled() {
|
||||
suite.updateConfigMapAndAwaitReconciliation(namespace, "standard/everything-disabled")
|
||||
suite.makeGoldenAssertions(namespace, "standard/blobstore-subsequent-disable")
|
||||
}
|
||||
|
||||
func (suite *ApplianceTestSuite) TestDoesNotDeleteUnownedResources() {
|
||||
namespace, err := k8senvtest.NewRandomNamespace("test-appliance")
|
||||
suite.Require().NoError(err)
|
||||
_, err = suite.k8sClient.CoreV1().Namespaces().Create(suite.ctx, namespace, metav1.CreateOptions{})
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Example: the admin configures a pgsql secret that references an external
|
||||
// database, and therefore disables pgsql in appliance config.
|
||||
secret := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: pgsqlSecretName,
|
||||
},
|
||||
StringData: map[string]string{
|
||||
"host": "example.com",
|
||||
"port": "5432",
|
||||
"user": "alice",
|
||||
"password": "letmein",
|
||||
"database": "sg",
|
||||
},
|
||||
}
|
||||
_, err = suite.k8sClient.CoreV1().Secrets(namespace.Name).Create(suite.ctx, secret, metav1.CreateOptions{})
|
||||
suite.Require().NoError(err)
|
||||
|
||||
suite.awaitReconciliation(namespace.Name, func() {
|
||||
// This is an artificial test fixture that disables everything except
|
||||
// frontend, but this is representative of disabling pgsql.
|
||||
cfgMap := suite.newConfigMap(namespace.Name, "frontend/default")
|
||||
_, err := suite.k8sClient.CoreV1().ConfigMaps(namespace.GetName()).Create(suite.ctx, cfgMap, metav1.CreateOptions{})
|
||||
suite.Require().NoError(err)
|
||||
})
|
||||
|
||||
secretStillPresent, err := suite.k8sClient.CoreV1().Secrets(namespace.Name).Get(suite.ctx, pgsqlSecretName, metav1.GetOptions{})
|
||||
suite.Require().NoError(err)
|
||||
suite.Require().Equal("example.com", string(secretStillPresent.Data["host"]))
|
||||
}
|
||||
|
||||
@ -106,6 +106,16 @@ func (r *Reconciler) reconcileSymbolsStatefulSet(ctx context.Context, sg *config
|
||||
}
|
||||
|
||||
podTemplate := pod.NewPodTemplate(name, cfg)
|
||||
redisConnSpecs, err := r.getRedisSecrets(ctx, sg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
redisConnHash, err := configHash(redisConnSpecs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
podTemplate.Template.ObjectMeta.Annotations["checksum/redis"] = redisConnHash
|
||||
|
||||
podTemplate.Template.Spec.Containers = []corev1.Container{ctr}
|
||||
podTemplate.Template.Spec.ServiceAccountName = name
|
||||
podTemplate.Template.Spec.Volumes = []corev1.Volume{
|
||||
@ -122,7 +132,14 @@ func (r *Reconciler) reconcileSymbolsStatefulSet(ctx context.Context, sg *config
|
||||
sset.Spec.Template = podTemplate.Template
|
||||
sset.Spec.VolumeClaimTemplates = []corev1.PersistentVolumeClaim{pvc}
|
||||
|
||||
return reconcileObject(ctx, r, sg.Spec.Symbols, &sset, &appsv1.StatefulSet{}, sg, owner)
|
||||
ifChanged := struct {
|
||||
config.SymbolsSpec
|
||||
RedisConnSpecs
|
||||
}{
|
||||
SymbolsSpec: cfg,
|
||||
RedisConnSpecs: redisConnSpecs,
|
||||
}
|
||||
return reconcileObject(ctx, r, ifChanged, &sset, &appsv1.StatefulSet{}, sg, owner)
|
||||
}
|
||||
|
||||
func (r *Reconciler) reconcileSymbolsService(ctx context.Context, sg *config.Sourcegraph, owner client.Object) error {
|
||||
|
||||
@ -78,8 +78,8 @@ func (r *Reconciler) reconcileSyntectDeployment(ctx context.Context, sg *config.
|
||||
dep := deployment.NewDeployment(name, sg.Namespace, sg.Spec.RequestedVersion)
|
||||
dep.Spec.Replicas = pointers.Ptr(cfg.Replicas)
|
||||
dep.Spec.Strategy.RollingUpdate = &appsv1.RollingUpdateDeployment{
|
||||
MaxSurge: pointers.Ptr(intstr.FromInt(1)),
|
||||
MaxUnavailable: pointers.Ptr(intstr.FromInt(0)),
|
||||
MaxSurge: pointers.Ptr(intstr.FromInt32(1)),
|
||||
MaxUnavailable: pointers.Ptr(intstr.FromInt32(0)),
|
||||
}
|
||||
dep.Spec.Template = podTemplate.Template
|
||||
|
||||
|
||||
@ -114,7 +114,10 @@ resources:
|
||||
indexedSearch:
|
||||
disabled: true
|
||||
|
||||
openTelemetry:
|
||||
openTelemetryCollector:
|
||||
disabled: true
|
||||
|
||||
openTelemetryAgent:
|
||||
disabled: true
|
||||
|
||||
pgsql:
|
||||
|
||||
@ -151,7 +151,10 @@ resources:
|
||||
indexedSearch:
|
||||
disabled: true
|
||||
|
||||
openTelemetry:
|
||||
openTelemetryCollector:
|
||||
disabled: true
|
||||
|
||||
openTelemetryAgent:
|
||||
disabled: true
|
||||
|
||||
pgsql:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user