Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cedd705
fix(ui): support controlled listbox selections
AnouarMohamed Apr 22, 2026
31f87ce
feat(ui): allow copying flags across environments
AnouarMohamed Apr 22, 2026
b7fc096
Merge branch 'v2' into feat/ui-copy-flags-across-environments
AnouarMohamed Apr 29, 2026
7ef9123
Merge branch 'v2' into feat/ui-copy-flags-across-environments
AnouarMohamed May 27, 2026
79d517a
Merge branch 'v2' into feat/ui-copy-flags-across-environments
markphelps May 27, 2026
2f2cace
Merge branch 'v2' into feat/ui-copy-flags-across-environments
AnouarMohamed Jun 19, 2026
7fa480a
Fix UI test timeouts with explicit waits
AnouarMohamed May 27, 2026
ef3d381
fix: stabilize copy flag and azure signing tests
AnouarMohamed Jun 20, 2026
80ee882
fix: avoid duplicate lowkey vault alias
AnouarMohamed Jun 20, 2026
9d47eaa
Merge branch 'v2' into feat/ui-copy-flags-across-environments
markphelps Jun 23, 2026
5cf0628
Merge branch 'v2' into feat/ui-copy-flags-across-environments
AnouarMohamed Jun 25, 2026
a2a1e04
Merge branch 'v2' into feat/ui-copy-flags-across-environments
AnouarMohamed Jun 27, 2026
f8187db
Merge branch 'v2' into feat/ui-copy-flags-across-environments
markphelps Jul 11, 2026
db50065
fix(ui): address copy flag review feedback
AnouarMohamed Jul 12, 2026
40742be
Merge branch 'v2' into feat/ui-copy-flags-across-environments
AnouarMohamed Jul 12, 2026
775a411
Merge branch 'v2' into feat/ui-copy-flags-across-environments
markphelps Jul 12, 2026
a34f352
fix(ui): use rtk query for copy namespaces
AnouarMohamed Jul 12, 2026
eded70d
Merge branch 'v2' into feat/ui-copy-flags-across-environments
markphelps Jul 13, 2026
0fd088d
fix(ui): address copy flag review followups
AnouarMohamed Jul 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 80 additions & 74 deletions build/testing/integration.go
Original file line number Diff line number Diff line change
Expand Up @@ -980,111 +980,117 @@ func withAWSSecrets(fn testCaseFn) testCaseFn {

func withAzureSecrets(fn testCaseFn) testCaseFn {
return func(ctx context.Context, client *dagger.Client, base, flipt *dagger.Container, conf testConfig) func() error {
// Lowkey Vault container for Azure Key Vault emulation.
// Uses assumed identity mode which accepts any bearer token.
lowkeyVault := client.Container().
From("nagyesta/lowkey-vault:7.2.18").
// Register the vault with an alias matching the service hostname used by Dagger.
// Lowkey Vault creates vaults at https://<name>.localhost:<port> by default,
// so we alias it to the hostname that Flipt and the setup container will use.
// Note: <port> is a Lowkey Vault placeholder that is replaced at runtime with the --server.port value (8443).
WithEnvVariable("LOWKEY_ARGS", "--LOWKEY_VAULT_NAMES=default --LOWKEY_VAULT_ALIASES=default.localhost=lowkey-vault:<port>").
WithExposedPort(8443).
AsService()

// Use a helper container bound to the service to extract the cert
caCert := client.Container().
From("alpine/openssl").
WithServiceBinding("lowkey-vault", lowkeyVault).
WithExec([]string{
"sh", "-c",
"openssl s_client -connect lowkey-vault:8443 -showcerts </dev/null 2>/dev/null | openssl x509 -outform PEM > /ca.crt",
}).
File("/ca.crt")

// Setup container to generate GPG key and store it in Lowkey Vault.
_, err := client.Container().
// Generate the signing key as a Lowkey Vault import file. Dagger services
// can restart from the base container, so seeding via a one-off request is
// not durable across subsequent service starts.
lowkeyImport := client.Container().
From("golang:1.26-alpine").
WithEnvVariable("UNIQUE", uuid.NewString()).
WithExec([]string{"apk", "add", "--no-cache", "gnupg"}).
WithServiceBinding("lowkey-vault", lowkeyVault).
WithFile("/usr/local/share/ca-certificates/lowkey-vault.crt", caCert).
WithExec([]string{"update-ca-certificates"}).
WithExec([]string{
"sh", "-c",
gpgKeyGenerationScript + `
gpg --homedir /tmp/gpg --armor --export-secret-keys test-bot@flipt.io > /tmp/private-key.asc

# Wait for Lowkey Vault to be ready
for i in $(seq 1 30); do
wget -q --no-check-certificate -O /dev/null https://lowkey-vault:8443/ping 2>/dev/null && break
sleep 1
done

# Use a Go program to create the secret via the Azure SDK
mkdir -p /tmp/setup && cat > /tmp/setup/main.go << 'GOEOF'
package main

import (
"context"
"encoding/json"
"fmt"
"os"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
)

// fakeCredential implements azcore.TokenCredential for testing.
type fakeCredential struct{}

func (f *fakeCredential) GetToken(_ context.Context, _ policy.TokenRequestOptions) (azcore.AccessToken, error) {
return azcore.AccessToken{Token: "fake-token"}, nil
}

func main() {
ctx := context.Background()

client, err := azsecrets.NewClient(
"https://lowkey-vault:8443",
&fakeCredential{},
&azsecrets.ClientOptions{
DisableChallengeResourceVerification: true,
ClientOptions: azcore.ClientOptions{
APIVersion: "7.6",
},
},
)
keyData, err := os.ReadFile("/tmp/private-key.asc")
if err != nil {
fmt.Fprintf(os.Stderr, "client: %v\n", err)
fmt.Fprintf(os.Stderr, "read key: %v\n", err)
os.Exit(1)
}

keyData, err := os.ReadFile("/tmp/private-key.asc")
const vaultURI = "https://{{host}}:{{port}}"

backup := map[string]any{
"vaults": []map[string]any{{
"attributes": map[string]any{
"baseUri": vaultURI,
"created": 1781906166,
"deleted": nil,
"recoverableDays": 90,
"recoveryLevel": "Recoverable",
},
"certificates": map[string]any{},
"keys": map[string]any{},
"secrets": map[string]any{
"flipt-signing-key": map[string]any{
"versions": []map[string]any{{
"attributes": map[string]any{
"created": 1781906396,
"enabled": true,
"recoverableDays": 90,
"recoveryLevel": "Recoverable",
"updated": 1781906396,
},
"entityId": "flipt-signing-key",
"entityVersion": "00000000000000000000000000000001",
"managed": false,
"tags": map[string]string{},
"value": string(keyData),
"vaultBaseUri": vaultURI,
}},
},
},
}},
}

contents, err := json.Marshal(backup)
if err != nil {
fmt.Fprintf(os.Stderr, "read key: %v\n", err)
fmt.Fprintf(os.Stderr, "marshal import: %v\n", err)
os.Exit(1)
}

value := string(keyData)
_, err = client.SetSecret(ctx, "flipt-signing-key", azsecrets.SetSecretParameters{
Value: &value,
}, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "set secret: %v\n", err)
if err := os.WriteFile("/tmp/lowkey-import.json", contents, 0o600); err != nil {
fmt.Fprintf(os.Stderr, "write import: %v\n", err)
os.Exit(1)
}

fmt.Println("Secret stored successfully in Lowkey Vault")
fmt.Println("Lowkey Vault import file generated")
}
GOEOF
cd /tmp/setup && go mod init setup && go mod tidy && go run main.go
cd /tmp/setup && go mod init setup && go run main.go
`,
}).Sync(ctx)
if err != nil {
return func() error { return err }
}
}).
File("/tmp/lowkey-import.json")

// Lowkey Vault container for Azure Key Vault emulation.
// Uses assumed identity mode which accepts any bearer token.
lowkeyVault := client.Container().
From("nagyesta/lowkey-vault:7.2.18").
WithFile("/import/backup.json", lowkeyImport).
WithEnvVariable("LOWKEY_ARGS", "--LOWKEY_VAULT_NAMES=default").
WithEnvVariable("LOWKEY_IMPORT_LOCATION", "/import/backup.json").
WithEnvVariable("LOWKEY_IMPORT_TEMPLATE_HOST", "lowkey-vault").
WithEnvVariable("LOWKEY_IMPORT_TEMPLATE_PORT", "8443").
WithExposedPort(8443).
AsService()

// Use a helper container bound to the service to extract the cert
caCert := client.Container().
From("alpine/openssl").
WithServiceBinding("lowkey-vault", lowkeyVault).
WithExec([]string{
"sh", "-c",
`for i in $(seq 1 30); do
if openssl s_client -connect lowkey-vault:8443 -showcerts </dev/null 2>/dev/null | openssl x509 -outform PEM > /ca.crt 2>/dev/null && [ -s /ca.crt ]; then
exit 0
fi
sleep 1
done
echo "ERROR: failed to extract Lowkey Vault certificate"
exit 1`,
}).
File("/ca.crt")

var err error
flipt, err = flipt.WithFile("/usr/local/share/ca-certificates/lowkey-vault.crt", caCert).
WithUser("root").
WithExec([]string{
Expand Down
33 changes: 17 additions & 16 deletions ui/src/app/flags/Flag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,14 @@ import {
selectRevision
} from '~/app/environments/environmentsApi';
import { selectInfo } from '~/app/meta/metaSlice';
import {
selectCurrentNamespace,
selectNamespaces
} from '~/app/namespaces/namespacesApi';
import { selectCurrentNamespace } from '~/app/namespaces/namespacesApi';

import { Badge } from '~/components/Badge';
import Dropdown from '~/components/Dropdown';
import Loading from '~/components/Loading';
import { PageHeader } from '~/components/Page';
import FlagForm from '~/components/flags/FlagForm';
import CopyToNamespacePanel from '~/components/panels/CopyToNamespacePanel';
import CopyFlagPanel from '~/components/panels/CopyFlagPanel';
import DeletePanel from '~/components/panels/DeletePanel';

import { FlagType, flagTypeToLabel } from '~/types/Flag';
Expand All @@ -53,7 +50,6 @@ export default function Flag() {
const navigate = useNavigate();

const environment = useSelector(selectCurrentEnvironment);
const namespaces = useSelector(selectNamespaces);
const namespace = useSelector(selectCurrentNamespace);

const revision = useSelector(selectRevision);
Expand Down Expand Up @@ -118,22 +114,28 @@ export default function Flag() {
/>

{/* flag copy modal */}
<CopyToNamespacePanel
<CopyFlagPanel
open={showCopyFlagModal}
panelMessage={
<>
Copy the flag{' '}
<span className="font-medium text-brand">{flag.key}</span> to the
namespace:
selected environment and namespace:
</>
}
panelType="Flag"
setOpen={setShowCopyFlagModal}
handleCopy={(namespaceKey: string) =>
handleCopy={({ environmentKey, namespaceKey }) =>
copyFlag({
environmentKey: environment.key,
from: { namespaceKey: namespace.key, flagKey: flag.key },
to: { namespaceKey: namespaceKey, flagKey: flag.key }
from: {
environmentKey: environment.key,
namespaceKey: namespace.key,
flagKey: flag.key
},
to: {
environmentKey,
namespaceKey,
flagKey: flag.key
}
}).unwrap()
}
onSuccess={() => {
Expand Down Expand Up @@ -199,10 +201,9 @@ export default function Flag() {
},
{
id: 'flag-copy',
label: 'Copy to Namespace',
disabled: namespaces.length < 2,
label: 'Copy to Environment / Namespace',
onClick: () => {
setShowCopyFlagModal(true);
window.setTimeout(() => setShowCopyFlagModal(true), 0);
},
icon: FilesIcon
},
Expand Down
43 changes: 28 additions & 15 deletions ui/src/app/flags/flagsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createApi } from '@reduxjs/toolkit/query/react';
import { SortingState } from '@tanstack/react-table';

import { IFlag, IFlagList } from '~/types/Flag';
import { INamespaceList } from '~/types/Namespace';
import { IResourceListResponse, IResourceResponse } from '~/types/Resource';
import { IRollout } from '~/types/Rollout';
import { IRule } from '~/types/Rule';
Expand Down Expand Up @@ -205,19 +206,21 @@ export const flagsApi = createApi({
copyFlag: builder.mutation<
void,
{
environmentKey: string;
from: { namespaceKey: string; flagKey: string };
to: { namespaceKey: string; flagKey: string };
from: {
environmentKey: string;
namespaceKey: string;
flagKey: string;
};
to: {
environmentKey: string;
namespaceKey: string;
flagKey: string;
};
}
>({
queryFn: async (
{ environmentKey, from, to },
_api,
_extraOptions,
baseQuery
) => {
queryFn: async ({ from, to }, _api, _extraOptions, baseQuery) => {
let resp = await baseQuery({
url: `/${environmentKey}/namespaces/${from.namespaceKey}/resources/flipt.core.Flag/${from.flagKey}`,
url: `/${from.environmentKey}/namespaces/${from.namespaceKey}/resources/flipt.core.Flag/${from.flagKey}`,
method: 'GET'
});
if (resp.error) {
Expand All @@ -229,14 +232,24 @@ export const flagsApi = createApi({
revision: string;
};

let data = {
resp = await baseQuery({
url: `/${to.environmentKey}/namespaces`,
method: 'GET'
});
if (resp.error) {
return { error: resp.error };
}

const destination = resp.data as INamespaceList;

const data = {
key: res.resource.key,
payload: res.resource.payload,
revision: res.revision
revision: destination.revision
};

resp = await baseQuery({
url: `/${environmentKey}/namespaces/${to.namespaceKey}/resources`,
url: `/${to.environmentKey}/namespaces/${to.namespaceKey}/resources`,
method: 'POST',
body: data
});
Expand All @@ -245,8 +258,8 @@ export const flagsApi = createApi({
}
return { data: undefined };
},
invalidatesTags: (_result, _error, { environmentKey, to }) => [
{ type: 'Flag', id: environmentKey + '/' + to.namespaceKey }
invalidatesTags: (_result, _error, { to }) => [
{ type: 'Flag', id: to.environmentKey + '/' + to.namespaceKey }
]
})
})
Expand Down
23 changes: 18 additions & 5 deletions ui/src/components/forms/Listbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,30 +12,43 @@ import { ISelectable } from '~/types/Selectable';
type ListBoxProps<T extends ISelectable> = {
id: string;
name: string;
ariaLabelledBy?: string;
values?: T[];
selected: T;
selected?: T;
setSelected?: (v: T) => void;
disabled?: boolean;
className?: string;
};

export default function Listbox<T extends ISelectable>(props: ListBoxProps<T>) {
const { id, name, values, selected, setSelected, disabled, className } =
props;
const {
id,
name,
ariaLabelledBy,
values,
selected,
setSelected,
disabled,
className
} = props;

return (
<Select
name={name}
disabled={disabled}
defaultValue={selected?.key}
value={selected?.key}
onValueChange={(key) => {
if (setSelected) {
const value = values?.find((el) => el.key == key) as T;
value && setSelected(value);
}
}}
>
<SelectTrigger id={`${id}-select-button`} className={className}>
<SelectTrigger
id={`${id}-select-button`}
aria-labelledby={ariaLabelledBy}
className={className}
>
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
Expand Down
Loading
Loading