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
```

d47b4cc48b being 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:
Craig Furman 2024-07-31 18:26:56 +01:00 committed by GitHub
parent 162d3836da
commit d24e8fe7f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
234 changed files with 8986 additions and 1845 deletions

View File

@ -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,

View File

@ -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>

View File

@ -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,

View File

@ -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

View File

@ -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}
/>
)}

View File

@ -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">

View File

@ -15,6 +15,7 @@ export interface SiteAdminSideBarGroupContext extends BatchChangesProps {
isSourcegraphDotCom: boolean
codeInsightsEnabled: boolean
endUserOnboardingEnabled: boolean
applianceManaged: boolean
}
export interface SiteAdminSideBarGroup extends NavGroupDescriptor<SiteAdminSideBarGroupContext> {}

View File

@ -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',

View File

@ -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",

View File

@ -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 {

View File

@ -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()

View File

@ -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(),

View File

@ -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",

View File

@ -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",
],
)

View 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()

View 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

View 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 "$@"

View 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
View File

@ -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
View File

@ -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=

View File

@ -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",
],
)

View File

@ -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
}

View File

@ -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)
}

View File

@ -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")
}
})
}
}

View File

@ -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",
],
)

View File

@ -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
}

View File

@ -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
}

View File

@ -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 {

View 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

View File

@ -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

View File

@ -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
)

View File

@ -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")
}

View File

@ -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")

View File

@ -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

View 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

View 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;
}
}

View File

@ -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>
)
}

View File

@ -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'

View File

@ -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>
)

View File

@ -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'

View File

@ -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}

View File

@ -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>
)
)
}

View File

@ -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" />}

View File

@ -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)

View File

@ -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(() => {

View File

@ -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>
)
}

View File

@ -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()
}
})
}

View File

@ -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)

View 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()
}
})
}

View 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",
],
)

View 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)
}

View 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},
},
}
}

View 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")
}

View File

@ -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
View 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
})
}

View 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)
}
}

View File

@ -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",

View 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.

View File

@ -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 }

View 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
}

View File

@ -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.

View File

@ -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"],
)

View File

@ -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) {

View File

@ -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

View File

@ -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 {

View File

@ -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__"],
)

View File

@ -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()
}

View File

@ -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__"],
)

View File

@ -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")
}

View File

@ -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"`
}

View File

@ -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://&lt;ip-address-above&gt;/</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>

View File

@ -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/

View File

@ -1,8 +0,0 @@
apiVersion: v2
name: operator
description: Operator Maintenance UI
type: application
version: v0.0.5999860
appVersion: "v0.0.5999860"

View File

@ -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
--------------------------------------------------------------

View File

@ -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

View File

@ -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 }}

View File

@ -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

View File

@ -1,4 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: {{ .Values.namespace }}

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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
}

View File

@ -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)
}
})
}
}

View File

@ -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 {

View File

@ -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)
}

View File

@ -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",
}

View File

@ -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 {

View 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)
}

View 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)
})
}
}

View File

@ -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

View File

@ -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

View File

@ -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 {

View File

@ -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 {

View 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
}

View File

@ -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"]))
}

View File

@ -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 {

View File

@ -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

View File

@ -114,7 +114,10 @@ resources:
indexedSearch:
disabled: true
openTelemetry:
openTelemetryCollector:
disabled: true
openTelemetryAgent:
disabled: true
pgsql:

View File

@ -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